@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
@@ -37,6 +37,7 @@
37
37
 
38
38
  import { readBlobFromPath, writeBlobToPath } from "./git";
39
39
  import { sortedJsonReplacer } from "../utils";
40
+ import { EFFECT_RECEIPT_ENTITY_TYPE } from "../effect-receipt";
40
41
 
41
42
  /** The file name under `<environment>/` on the orphan branch. */
42
43
  export const OBSERVATION_BASELINE_FILE = "observation-baseline.json";
@@ -140,19 +141,50 @@ export interface DeviationToAccept {
140
141
  note?: string;
141
142
  }
142
143
 
144
+ /** Options for {@link acceptDeviations}. */
145
+ export interface AcceptDeviationsOptions {
146
+ /** ISO timestamp to stamp instead of the wall clock (tests, replays). */
147
+ now?: string;
148
+ /**
149
+ * Entity names the caller recognized as effect receipts (#1833) — the
150
+ * marker-based read (`collectEffectReceipts`, ../effect-receipt.ts) over
151
+ * the build's entities, which is how a lexicon-materialized receipt row is
152
+ * caught even though its `entityType` is the row's own. Core-typed
153
+ * receipts are refused regardless, via {@link DeviationToAccept.type}.
154
+ */
155
+ receipts?: ReadonlySet<string>;
156
+ }
157
+
143
158
  /**
144
159
  * Record deviations as accepted, returning a new baseline (the input is not
145
160
  * mutated). An existing acceptance for the same entity+path is replaced — that
146
161
  * is how re-accepting after a deliberate change works, and it keeps the file
147
162
  * from growing a second entry for every value a path has ever held.
163
+ *
164
+ * REFUSES a deviation on an effect receipt (#1833, epic #1703), throwing
165
+ * before anything is recorded: a receipt is the declared witness that its
166
+ * effect ran, and the `effect()` step is its only writer — baselining a
167
+ * receipt's live value would accept "the effect never fired" as the new
168
+ * normal and silently defuse the effect. The whole acceptance aborts, not
169
+ * just the receipt's row, so a partial baseline never lands.
148
170
  */
149
171
  export function acceptDeviations(
150
172
  baseline: ObservationBaseline,
151
173
  lexicon: string,
152
174
  deviations: readonly DeviationToAccept[],
153
- opts?: { now?: string },
175
+ opts?: AcceptDeviationsOptions,
154
176
  ): ObservationBaseline {
155
177
  if (deviations.length === 0) return baseline;
178
+ for (const dev of deviations) {
179
+ if (dev.type === EFFECT_RECEIPT_ENTITY_TYPE || opts?.receipts?.has(dev.entity)) {
180
+ throw new Error(
181
+ `cannot accept drift on effect receipt "${dev.entity}" (path ${dev.path}): ` +
182
+ `the effect() step is the receipt's only writer — accepting the live value would ` +
183
+ `silently defuse the effect. Re-run the effect (or change its inputs) instead of ` +
184
+ `baselining the receipt.`,
185
+ );
186
+ }
187
+ }
156
188
  const now = opts?.now ?? new Date().toISOString();
157
189
  const lexicons: Record<string, BaselineLexicon> = { ...baseline.lexicons };
158
190
  const entities: BaselineLexicon = { ...(lexicons[lexicon] ?? {}) };
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ planReceipts,
4
+ mergeReceiptEntries,
5
+ observedValueResolver,
6
+ readReceiptValue,
7
+ type ReceiptReading,
8
+ } from "./receipt-plan";
9
+ import { buildChangeSet, renderChangeSet, summarize, type ChangeSet } from "./change-set";
10
+ import {
11
+ EffectReceipt,
12
+ EXISTENCE_EXPECTATION,
13
+ receiptExpectation,
14
+ resolveReceiptExpectation,
15
+ collectEffectReceipts,
16
+ type EffectReceiptDeclaration,
17
+ } from "../effect-receipt";
18
+ import { AttrRef } from "../attrref";
19
+ import type { ResourceMetadata } from "../lexicon";
20
+ import type { ReceiptInputResolver } from "../effect-receipt";
21
+
22
+ const noRefs: ReceiptInputResolver = (_ref, path) => {
23
+ throw new Error(`unexpected reference at ${path}`);
24
+ };
25
+
26
+ function receiptsOf(...decls: Array<[string, EffectReceiptDeclaration]>): Map<string, EffectReceiptDeclaration> {
27
+ return new Map(decls);
28
+ }
29
+
30
+ describe("planReceipts", () => {
31
+ it("absent receipt proposes the fire", () => {
32
+ const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
33
+ const readings = new Map<string, ReceiptReading>([
34
+ ["dbMigrated", { observed: true, present: false, lexicon: "aws" }],
35
+ ]);
36
+
37
+ const entries = planReceipts(receiptsOf(["dbMigrated", r]), readings, noRefs);
38
+
39
+ expect(entries).toHaveLength(1);
40
+ expect(entries[0].action).toBe("effect");
41
+ expect(entries[0].effect).toBe("db-migrate");
42
+ expect(entries[0].effectReason).toBe("receipt-absent");
43
+ expect(entries[0].evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: true });
44
+ });
45
+
46
+ it("stale hash proposes the fire, with both values in the detail", () => {
47
+ const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { rows: 10 } });
48
+ const expected = receiptExpectation(r);
49
+ const readings = new Map<string, ReceiptReading>([
50
+ ["seeded", { observed: true, present: true, value: "sha256:0000", lexicon: "aws" }],
51
+ ]);
52
+
53
+ const entries = planReceipts(receiptsOf(["seeded", r]), readings, noRefs);
54
+
55
+ expect(entries[0].action).toBe("effect");
56
+ expect(entries[0].effectReason).toBe("receipt-stale");
57
+ expect(entries[0].effectDetail).toContain("sha256:0000");
58
+ expect(entries[0].effectDetail).toContain(expected);
59
+ });
60
+
61
+ it("matching receipt is a clean noop", () => {
62
+ const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { rows: 10 } });
63
+ const readings = new Map<string, ReceiptReading>([
64
+ ["seeded", { observed: true, present: true, value: receiptExpectation(r), lexicon: "aws" }],
65
+ ]);
66
+
67
+ const entries = planReceipts(receiptsOf(["seeded", r]), readings, noRefs);
68
+
69
+ expect(entries[0].action).toBe("noop");
70
+ expect(entries[0].effect).toBe("seed");
71
+ });
72
+
73
+ it("matching existence receipt is a clean noop", () => {
74
+ const r = EffectReceipt("bootstrapped", { effect: "bootstrap", flavor: "existence" });
75
+ const readings = new Map<string, ReceiptReading>([
76
+ ["bootstrapped", { observed: true, present: true, value: EXISTENCE_EXPECTATION }],
77
+ ]);
78
+
79
+ expect(planReceipts(receiptsOf(["bootstrapped", r]), readings, noRefs)[0].action).toBe("noop");
80
+ });
81
+
82
+ it("a lexicon that could not look yields unobserved with its reason, never a clean row", () => {
83
+ const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
84
+ const readings = new Map<string, ReceiptReading>([
85
+ ["dbMigrated", { observed: false, present: false, unobservedReason: "no-credentials", unobservedDetail: "no AWS credentials" }],
86
+ ]);
87
+
88
+ const entries = planReceipts(receiptsOf(["dbMigrated", r]), readings, noRefs);
89
+
90
+ expect(entries[0].action).toBe("unobserved");
91
+ expect(entries[0].unobservedReason).toBe("no-credentials");
92
+ expect(entries[0].unobservedDetail).toBe("no AWS credentials");
93
+ });
94
+
95
+ it("a receipt no lexicon observes yields unobserved, loudly, never silently clean", () => {
96
+ const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
97
+
98
+ const entries = planReceipts(receiptsOf(["dbMigrated", r]), new Map(), noRefs);
99
+
100
+ expect(entries[0].action).toBe("unobserved");
101
+ expect(entries[0].unobservedReason).toBe("unsupported-kind");
102
+ expect(entries[0].unobservedDetail).toContain("db-migrate");
103
+ expect(entries[0].unobservedDetail).toContain("unknown, not clean");
104
+ });
105
+
106
+ it("a reference that cannot resolve at plan time proposes the fire with an unresolved-input note", () => {
107
+ const parent = { entityType: "Test::Db" };
108
+ const ref = new AttrRef(parent, "endpoint");
109
+ ref._setLogicalName("db");
110
+ const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { endpoint: ref } });
111
+ const readings = new Map<string, ReceiptReading>([
112
+ ["seeded", { observed: true, present: true, value: "sha256:live" }],
113
+ ]);
114
+
115
+ // Resolver over observed values that do NOT include db.endpoint.
116
+ const entries = planReceipts(receiptsOf(["seeded", r]), readings, observedValueResolver({}));
117
+
118
+ expect(entries[0].action).toBe("effect");
119
+ expect(entries[0].effectReason).toBe("unresolved-input");
120
+ expect(entries[0].effectDetail).toContain("unresolved input");
121
+ expect(entries[0].effectDetail).toContain("db.endpoint");
122
+ });
123
+
124
+ it("a reference input resolves against observed values and the comparison uses the resolved digest", () => {
125
+ const parent = { entityType: "Test::Db" };
126
+ const ref = new AttrRef(parent, "endpoint");
127
+ ref._setLogicalName("db");
128
+ const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { endpoint: ref } });
129
+
130
+ const observed: Record<string, ResourceMetadata> = {
131
+ db: { type: "Test::Db", status: "ok", attributes: { endpoint: "db.example.internal:5432" } },
132
+ };
133
+ const resolver = observedValueResolver(observed);
134
+ const expected = resolveReceiptExpectation(r, resolver);
135
+ const readings = new Map<string, ReceiptReading>([
136
+ ["seeded", { observed: true, present: true, value: expected }],
137
+ ]);
138
+
139
+ expect(planReceipts(receiptsOf(["seeded", r]), readings, resolver)[0].action).toBe("noop");
140
+ });
141
+
142
+ it("crash between effect and receipt write: the stale receipt re-proposes the fire on the next plan", () => {
143
+ // Run 1 wrote the receipt for inputs { schema: 1 }. The effect for
144
+ // { schema: 2 } ran, then the process died before the receipt write —
145
+ // the live value still carries run 1's digest.
146
+ const run1 = EffectReceipt("migrated", { effect: "migrate", flavor: "hash", inputs: { schema: 1 } });
147
+ const run2 = EffectReceipt("migrated", { effect: "migrate", flavor: "hash", inputs: { schema: 2 } });
148
+ const readings = new Map<string, ReceiptReading>([
149
+ ["migrated", { observed: true, present: true, value: receiptExpectation(run1) }],
150
+ ]);
151
+
152
+ const entries = planReceipts(receiptsOf(["migrated", run2]), readings, noRefs);
153
+
154
+ expect(entries[0].action).toBe("effect");
155
+ expect(entries[0].effectReason).toBe("receipt-stale");
156
+
157
+ // The first-run variant of the same crash: the effect ran, the write
158
+ // never happened, the receipt is absent — the fire is proposed again.
159
+ const absent = new Map<string, ReceiptReading>([["migrated", { observed: true, present: false }]]);
160
+ expect(planReceipts(receiptsOf(["migrated", run2]), absent, noRefs)[0].effectReason).toBe("receipt-absent");
161
+ });
162
+ });
163
+
164
+ describe("mergeReceiptEntries", () => {
165
+ it("replaces the generic create proposed for an absent receipt with the effect row", () => {
166
+ const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
167
+ const receipts = receiptsOf(["dbMigrated", r]);
168
+
169
+ // The generic classification knows nothing about receipts: declared and
170
+ // confirmed absent classifies as create.
171
+ const cs = buildChangeSet("prod", {
172
+ declared: new Set(["dbMigrated", "bucket"]),
173
+ observedNow: { bucket: { type: "Test::Bucket", status: "ok" } },
174
+ observedThen: undefined,
175
+ });
176
+ expect(cs.entries.find((e) => e.name === "dbMigrated")?.action).toBe("create");
177
+
178
+ const entries = planReceipts(receipts, new Map([["dbMigrated", { observed: true, present: false } as ReceiptReading]]), noRefs);
179
+ mergeReceiptEntries(cs, receipts, entries);
180
+
181
+ const row = cs.entries.filter((e) => e.name === "dbMigrated");
182
+ expect(row).toHaveLength(1);
183
+ expect(row[0].action).toBe("effect");
184
+ expect(summarize(cs).create).toBe(0);
185
+ });
186
+
187
+ it("a live receipt is never a prune candidate: the generic delete is replaced", () => {
188
+ const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
189
+ const receipts = receiptsOf(["dbMigrated", r]);
190
+
191
+ // Worst case for the write-exclusion: the receipt is live and owned but
192
+ // missing from the declared axis the change set saw (exactly what
193
+ // withholding it from the apply-bound set produces) — the generic
194
+ // classification proposes delete, the prune candidacy a receipt must
195
+ // never have.
196
+ const cs = buildChangeSet("prod", {
197
+ declared: new Set<string>(),
198
+ observedNow: {
199
+ dbMigrated: { type: "AWS::SSM::Parameter", status: "ok", ownership: "owned" },
200
+ },
201
+ observedThen: undefined,
202
+ });
203
+ expect(cs.entries.find((e) => e.name === "dbMigrated")?.action).toBe("delete");
204
+
205
+ const entries = planReceipts(
206
+ receipts,
207
+ new Map([["dbMigrated", { observed: true, present: true, value: EXISTENCE_EXPECTATION } as ReceiptReading]]),
208
+ noRefs,
209
+ );
210
+ mergeReceiptEntries(cs, receipts, entries);
211
+
212
+ expect(cs.entries.filter((e) => e.action === "delete")).toHaveLength(0);
213
+ expect(cs.entries.filter((e) => e.action === "adopt")).toHaveLength(0);
214
+ expect(cs.entries.find((e) => e.name === "dbMigrated")?.action).toBe("noop");
215
+ });
216
+ });
217
+
218
+ describe("renderChangeSet with effect entries", () => {
219
+ it('renders "effect will fire: <effect>"', () => {
220
+ const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
221
+ const cs: ChangeSet = { env: "prod", entries: [] };
222
+ mergeReceiptEntries(
223
+ cs,
224
+ receiptsOf(["dbMigrated", r]),
225
+ planReceipts(receiptsOf(["dbMigrated", r]), new Map([["dbMigrated", { observed: true, present: false } as ReceiptReading]]), noRefs),
226
+ );
227
+
228
+ const rendered = renderChangeSet(cs);
229
+ expect(rendered).toContain("effect will fire: db-migrate");
230
+ expect(rendered).toContain("1 effect");
231
+ expect(rendered).toContain("the generic apply never writes a receipt");
232
+ });
233
+ });
234
+
235
+ describe("readReceiptValue", () => {
236
+ it("reads value, falling back to the provider's Value casing", () => {
237
+ expect(readReceiptValue({ value: "a" })).toBe("a");
238
+ expect(readReceiptValue({ Value: "b" })).toBe("b");
239
+ expect(readReceiptValue({ value: "a", Value: "b" })).toBe("a");
240
+ expect(readReceiptValue(undefined)).toBeUndefined();
241
+ });
242
+ });
243
+
244
+ describe("collectEffectReceipts over a mixed entity map", () => {
245
+ it("finds only marker-carrying entities", () => {
246
+ const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
247
+ const entities = new Map<string, EffectReceiptDeclaration>([["dbMigrated", r]]);
248
+ expect([...collectEffectReceipts(entities).keys()]).toEqual(["dbMigrated"]);
249
+ });
250
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Receipt planning (#1832, epic #1703) — the plan half of the effect-receipt
3
+ * contract.
4
+ *
5
+ * A receipt is declared, diffed, and observed like any resource, but it is
6
+ * OBSERVE-ONLY to the generic apply path: the `effect()` step (#1834) is the
7
+ * sole writer, on success, last (epic decision log, item 3). The plan's job is
8
+ * therefore not "create the receipt" — it is "say whether the effect will
9
+ * fire". This module compares each declared receipt's live value against its
10
+ * resolved expectation and classifies:
11
+ *
12
+ * - absent or differing → an `effect` entry ("effect will fire") — including
13
+ * the crash-between-effect-and-write case, where a stale or missing receipt
14
+ * re-proposes the fire on the next plan. That re-proposal is the whole
15
+ * at-least-once guarantee; anything that stamps the receipt on the generic
16
+ * path silently converts it into never.
17
+ * - equal → `noop` — the effect has run for these inputs; the plan is clean.
18
+ * - unobservable → `unobserved`, loudly. A receipt nobody could read is a
19
+ * hole in the plan, never a clean row.
20
+ * - a reference input that cannot resolve at plan time → an `effect` entry
21
+ * with an "unresolved input" note, never a guessed digest. The effect step
22
+ * resolves again at run and decides there.
23
+ *
24
+ * A receipt name is also stripped out of whatever the generic change-set
25
+ * classification proposed for it ({@link mergeReceiptEntries}): a receipt is
26
+ * never a `create`, never an `update`, and never a `delete`/`adopt` prune
27
+ * candidate — the entries built here are the only rows a receipt gets.
28
+ *
29
+ * Pure: no I/O. Live state arrives as {@link ReceiptReading}s the caller
30
+ * built from its observations; reference inputs resolve through the
31
+ * caller-supplied resolver ({@link observedValueResolver} builds one over the
32
+ * plan's merged observed values).
33
+ */
34
+ import { AttrRef } from "../attrref";
35
+ import {
36
+ EXISTENCE_EXPECTATION,
37
+ resolveReceiptExpectation,
38
+ type EffectReceiptDeclaration,
39
+ type ReceiptInputResolver,
40
+ } from "../effect-receipt";
41
+ import type { ResourceMetadata } from "../lexicon";
42
+ import type { UnobservedReason } from "../observation";
43
+ import type { ChangeSet, ChangeSetEntry } from "./change-set";
44
+
45
+ /**
46
+ * The attribute a materialized receipt's live value is read from. A lexicon
47
+ * receipt row's observation (#1835) maps the stored value onto
48
+ * `attributes[RECEIPT_VALUE_ATTRIBUTE]`; `Value` is accepted as a fallback for
49
+ * observations that keep the provider's own casing (SSM's `Value`).
50
+ */
51
+ export const RECEIPT_VALUE_ATTRIBUTE = "value";
52
+
53
+ /** Read a live receipt's stored value off an observation's attributes. */
54
+ export function readReceiptValue(attributes: Record<string, unknown> | undefined): unknown {
55
+ if (!attributes) return undefined;
56
+ if (RECEIPT_VALUE_ATTRIBUTE in attributes) return attributes[RECEIPT_VALUE_ATTRIBUTE];
57
+ return attributes["Value"];
58
+ }
59
+
60
+ /**
61
+ * What the plan's observation pass learned about one declared receipt.
62
+ * Deliberately tri-state, the same shape the change set rests on (#1089):
63
+ * `observed: false` means "nobody looked", which is a hole — never absence.
64
+ * A receipt with no reading at all means no loaded lexicon even claims it.
65
+ */
66
+ export interface ReceiptReading {
67
+ /** The lexicon actually looked. `false` → the receipt is a plan hole. */
68
+ observed: boolean;
69
+ /** Observed present in the live system. Meaningful only when `observed`. */
70
+ present: boolean;
71
+ /** The live receipt's stored value ({@link readReceiptValue}). */
72
+ value?: unknown;
73
+ /** Live resource type, when reported. */
74
+ type?: string;
75
+ /** Provider-assigned physical id, when reported. */
76
+ physicalId?: string;
77
+ /** The lexicon whose observation produced this reading. */
78
+ lexicon?: string;
79
+ /** Why the read did not happen, when `observed: false`. */
80
+ unobservedReason?: UnobservedReason;
81
+ unobservedDetail?: string;
82
+ }
83
+
84
+ /**
85
+ * A {@link ReceiptInputResolver} over the plan's merged observed values: an
86
+ * attr-ref resolves to the referenced entity's observed attribute. Anything
87
+ * that is not among the observed values throws — which `planReceipts` renders
88
+ * as an effect-will-fire entry with an "unresolved input" note rather than a
89
+ * guessed expectation.
90
+ */
91
+ export function observedValueResolver(
92
+ resources: Readonly<Record<string, ResourceMetadata>>,
93
+ ): ReceiptInputResolver {
94
+ return (ref, path) => {
95
+ if (ref instanceof AttrRef) {
96
+ const entity = ref.getLogicalName();
97
+ if (!entity) {
98
+ throw new Error(`unresolved input at ${path}: attr-ref carries no logical name`);
99
+ }
100
+ const attrs = resources[entity]?.attributes;
101
+ if (!attrs || !(ref.attribute in attrs)) {
102
+ throw new Error(
103
+ `unresolved input at ${path}: ${entity}.${ref.attribute} is not among the observed values`,
104
+ );
105
+ }
106
+ return attrs[ref.attribute];
107
+ }
108
+ throw new Error(`unresolved input at ${path}: reference cannot resolve at plan time`);
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Classify every declared receipt into its change-set entry. See the module
114
+ * doc for the classification; entries come back sorted by name.
115
+ */
116
+ export function planReceipts(
117
+ receipts: ReadonlyMap<string, EffectReceiptDeclaration>,
118
+ readings: ReadonlyMap<string, ReceiptReading>,
119
+ resolver: ReceiptInputResolver,
120
+ ): ChangeSetEntry[] {
121
+ const entries: ChangeSetEntry[] = [];
122
+
123
+ for (const [name, receipt] of receipts) {
124
+ const reading = readings.get(name);
125
+ const base = {
126
+ name,
127
+ type: reading?.type ?? receipt.entityType,
128
+ ...(reading?.lexicon ? { lexicon: reading.lexicon } : {}),
129
+ ...(reading?.physicalId ? { physicalId: reading.physicalId } : {}),
130
+ effect: receipt.effect,
131
+ ownership: "unknown" as const,
132
+ };
133
+ const evidence = {
134
+ declared: true,
135
+ inSnapshot: false,
136
+ live: reading?.present ?? false,
137
+ observed: reading?.observed ?? false,
138
+ };
139
+
140
+ if (!reading) {
141
+ // No loaded lexicon materializes or observes this receipt — a hole,
142
+ // said loudly, never a silently clean row (#1089's tri-state).
143
+ entries.push({
144
+ ...base,
145
+ action: "unobserved",
146
+ evidence,
147
+ unobservedReason: "unsupported-kind",
148
+ unobservedDetail:
149
+ `no loaded lexicon materializes or observes this receipt — ` +
150
+ `whether effect "${receipt.effect}" has run is unknown, not clean`,
151
+ });
152
+ continue;
153
+ }
154
+
155
+ if (!reading.observed) {
156
+ entries.push({
157
+ ...base,
158
+ action: "unobserved",
159
+ evidence,
160
+ unobservedReason: reading.unobservedReason ?? "read-failed",
161
+ ...(reading.unobservedDetail ? { unobservedDetail: reading.unobservedDetail } : {}),
162
+ });
163
+ continue;
164
+ }
165
+
166
+ // Resolve the expectation. A reference that cannot resolve at plan time
167
+ // is never guessed around: the fire is proposed with the note, and the
168
+ // effect step resolves again at run (epic decision log, item 5).
169
+ let expectation: string | undefined;
170
+ let unresolved: string | undefined;
171
+ try {
172
+ expectation = resolveReceiptExpectation(receipt, resolver);
173
+ } catch (err) {
174
+ unresolved = err instanceof Error ? err.message : String(err);
175
+ }
176
+
177
+ if (!reading.present) {
178
+ entries.push({
179
+ ...base,
180
+ action: "effect",
181
+ evidence,
182
+ effectReason: "receipt-absent",
183
+ effectDetail:
184
+ `receipt confirmed absent — no run of "${receipt.effect}" is recorded` +
185
+ (unresolved ? ` (${unresolved})` : ""),
186
+ });
187
+ continue;
188
+ }
189
+
190
+ if (expectation === undefined) {
191
+ entries.push({
192
+ ...base,
193
+ action: "effect",
194
+ evidence,
195
+ effectReason: "unresolved-input",
196
+ effectDetail: unresolved,
197
+ });
198
+ continue;
199
+ }
200
+
201
+ if (reading.value === expectation) {
202
+ entries.push({ ...base, action: "noop", evidence });
203
+ continue;
204
+ }
205
+
206
+ entries.push({
207
+ ...base,
208
+ action: "effect",
209
+ evidence,
210
+ effectReason: "receipt-stale",
211
+ effectDetail:
212
+ receipt.flavor === "existence"
213
+ ? `live value ${fmtValue(reading.value)} is not the existence marker "${EXISTENCE_EXPECTATION}"`
214
+ : `live value ${fmtValue(reading.value)} differs from expected ${expectation}`,
215
+ });
216
+ }
217
+
218
+ entries.sort((a, b) => a.name.localeCompare(b.name));
219
+ return entries;
220
+ }
221
+
222
+ /**
223
+ * Replace whatever the generic classification proposed for the receipts with
224
+ * the receipt entries built by {@link planReceipts}, in place.
225
+ *
226
+ * This is the plan-side write-exclusion (#1832): the generic change set,
227
+ * knowing nothing about receipts, classifies a declared-but-absent receipt as
228
+ * `create` and an undeclared-but-live one as `delete`/`adopt` — proposals the
229
+ * generic apply path must never act on for a receipt, and prune candidacy a
230
+ * receipt must never have. Every entry named like a receipt is dropped and
231
+ * the receipt's own entries stand in.
232
+ */
233
+ export function mergeReceiptEntries(
234
+ cs: ChangeSet,
235
+ receipts: ReadonlyMap<string, EffectReceiptDeclaration>,
236
+ receiptEntries: ChangeSetEntry[],
237
+ ): ChangeSet {
238
+ if (receipts.size === 0) return cs;
239
+ cs.entries = cs.entries.filter((e) => !receipts.has(e.name));
240
+ cs.entries.push(...receiptEntries);
241
+ cs.entries.sort((a, b) => a.name.localeCompare(b.name));
242
+ return cs;
243
+ }
244
+
245
+ function fmtValue(v: unknown): string {
246
+ if (v === undefined) return "<unset>";
247
+ const s = typeof v === "string" ? v : JSON.stringify(v);
248
+ return s.length > 72 ? `${s.slice(0, 69)}...` : s;
249
+ }
@@ -78,6 +78,26 @@ export interface ReleaseRecord {
78
78
  approver?: string;
79
79
  /** Optional: the archive's own manifest digest (../components/verbs/build-archive.ts's `manifestDigest`), when the caller has it — lets a reader recover full build contents/provenance, not just the promoted image digest. */
80
80
  manifestDigest?: string;
81
+ /**
82
+ * Optional: set when the deploy's capability-profile assertion (chant
83
+ * #1244, helm lexicon) was deliberately overridden — carries the named
84
+ * divergences that were bypassed (declared vs live), so the ledger shows
85
+ * this release knowingly skewed from its declared profile. Absent for a
86
+ * deploy whose target matched, and for deploys with no declared profile.
87
+ */
88
+ profileOverride?: string;
89
+ /**
90
+ * Optional: the deploy's input-side digest, when `digest` is a
91
+ * rendered-content identity rather than an input identity. A pinned helm
92
+ * deploy (chant #1242) records the render's `contentDigest` as `digest` —
93
+ * what this cluster actually received — and carries the input digest
94
+ * (chart, chart version, resolved values, capability facts; chant #1243)
95
+ * here, because profiles are per cluster: two environments legitimately
96
+ * render to different bytes, so cross-environment "is prod running what
97
+ * staging tested" joins on this field while `digest` proves the exact
98
+ * bytes each cluster got. Absent when `digest` is already input-side.
99
+ */
100
+ inputDigest?: string;
81
101
  }
82
102
 
83
103
  /** Required, non-empty-string fields every `ReleaseRecord` must carry. */