@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
package/src/lexicon.ts CHANGED
@@ -439,6 +439,24 @@ export interface BuildRootContext {
439
439
  projectRoot: string;
440
440
  /** The resolved project configuration, for the lexicon's own namespace. */
441
441
  config: Record<string, unknown>;
442
+ /**
443
+ * The discovered entities, read-only (#1828 / SOPS provenance). A
444
+ * contributor that reacts to what the project DECLARED — rather than to
445
+ * what its config listed — reads them here: the committed-encrypted
446
+ * `declareSecret()` hook resolves each declaration's `file` into a sidecar
447
+ * entity, and any future declaration-driven contributor needs the same.
448
+ *
449
+ * Read-only on purpose. The merge that follows is the only thing that adds
450
+ * to the entity set, and it refuses a name collision rather than
451
+ * overwriting; a contributor mutating the map directly would slip past
452
+ * that. Contributors run in order, so a contributor also sees entities
453
+ * earlier contributors added.
454
+ *
455
+ * Optional: a caller that has no entity set (a plugin hook invoked
456
+ * directly, a graph mode that never discovered) omits it, and a hook must
457
+ * treat an absent map as an empty one.
458
+ */
459
+ entities?: ReadonlyMap<string, Declarable>;
442
460
  }
443
461
 
444
462
  /**
@@ -450,6 +468,17 @@ export interface BuildRootContribution {
450
468
  warnings?: string[];
451
469
  }
452
470
 
471
+ /**
472
+ * A plugin's `buildRoots` hook, already bound to this invocation's project
473
+ * root and config (`collectBuildRootContributors`, ./cli/plugins.ts). What is
474
+ * NOT bindable that early is the entity set — discovery has not run yet — so
475
+ * the merge supplies it when it calls the closure. A contributor that ignores
476
+ * the argument is still assignable, which is what every pre-#1828 hook does.
477
+ */
478
+ export type BuildRootContributor = (
479
+ ctx: Pick<BuildRootContext, "entities">,
480
+ ) => Promise<BuildRootContribution>;
481
+
453
482
  export interface LexiconPlugin {
454
483
  // ── Required ──────────────────────────────────────────────
455
484
  /** Human-readable name (e.g. "aws", "gcp") */
@@ -540,6 +569,17 @@ export interface LexiconPlugin {
540
569
  */
541
570
  auditCatalog?(): Record<string, RuleMeta>;
542
571
 
572
+ /**
573
+ * Parse standalone template content (a file audit discovery classified for
574
+ * this lexicon) into the lexicon's entity graph, keyed the way `ctx.entities`
575
+ * is during a build. Lets entity-reading post-synth checks fire on
576
+ * `chant audit` of hand-written manifests — parse-to-graph rather than
577
+ * output-reading rule variants (#1567). Implementations must tolerate
578
+ * arbitrary external content: a malformed document yields no entities, never
579
+ * a throw. Omit for lexicons whose audit checks read `ctx.outputs`.
580
+ */
581
+ auditEntities?(content: string): Map<string, Declarable>;
582
+
543
583
  /**
544
584
  * Machine-readable spec-coverage accounting for `check-lexicon` (#1330).
545
585
  *
@@ -1115,10 +1155,15 @@ export interface TeardownOutcome {
1115
1155
  /**
1116
1156
  * - `deleted` — gone, including already-gone (deletion is idempotent);
1117
1157
  * - `failed` — the delete errored (core retries these once);
1118
- * - `not-prunable` — deliberately not deleted; `detail` says why.
1158
+ * - `not-prunable` — deliberately not deleted; `detail` says why;
1159
+ * - `retained` — owned and no longer declared, but deliberately kept
1160
+ * (#1365 decision 5): a `generated-once` secret never enters the prunable
1161
+ * set, because deleting it would destroy the only copy of material chant
1162
+ * never held. Reported loudly, never deleted; deletion is an explicit act
1163
+ * (`kubectl delete`, or a future gated op), never a sweep's.
1119
1164
  */
1120
- outcome: "deleted" | "failed" | "not-prunable";
1121
- /** The error for `failed`, the reason for `not-prunable`. */
1165
+ outcome: "deleted" | "failed" | "not-prunable" | "retained";
1166
+ /** The error for `failed`, the reason for `not-prunable`/`retained`. */
1122
1167
  detail?: string;
1123
1168
  }
1124
1169
 
@@ -31,11 +31,30 @@ import { unobservedReasonText, type UnobservedReason } from "../observation";
31
31
  * not drift, just the runtime doing its job. `runtimeOwner` names the
32
32
  * declared entity it belongs to.
33
33
  * - `noop` — declared and live with no drift, or already reconciled.
34
+ * - `effect` — a declared effect receipt (#1832) whose live value is absent or
35
+ * differs from the resolved expectation: the effect step will fire. Never a
36
+ * `create` or `update` — the generic apply path is observe-only to receipts,
37
+ * and the `effect()` step is the sole writer (epic #1703, decision 3). Read
38
+ * `effect` for the effect's identity and `effectReason` for why it fires.
34
39
  * - `unobserved` — declared, and the lexicon could not look (#1089). Not a
35
40
  * proposal at all: it is the plan admitting a hole. Never a create, never a
36
41
  * delete. Read `unobservedReason` for which hole.
37
42
  */
38
- export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "unobserved";
43
+ export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "effect" | "unobserved";
44
+
45
+ /**
46
+ * Why an `effect` entry proposes a fire (#1832).
47
+ *
48
+ * - `receipt-absent` — the provider confirmed the receipt absent: the effect
49
+ * has never recorded a run (or the run crashed before the write — the
50
+ * at-least-once case this classification exists to preserve).
51
+ * - `receipt-stale` — the receipt is live but its value differs from the
52
+ * resolved expectation: the effect's inputs changed since the last run.
53
+ * - `unresolved-input` — a reference input could not resolve at plan time, so
54
+ * the expectation cannot be computed. The fire is proposed rather than
55
+ * guessed away; the effect step resolves again at run.
56
+ */
57
+ export type EffectFireReason = "receipt-absent" | "receipt-stale" | "unresolved-input";
39
58
 
40
59
  /**
41
60
  * Who answers "is this resource chant's?". `unknown` until a live ownership
@@ -100,6 +119,16 @@ export interface ChangeSetEntry {
100
119
  queried?: string;
101
120
  /** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
102
121
  runtimeOwner?: string;
122
+ /**
123
+ * The effect a receipt witnesses (#1832), for entries derived from an effect
124
+ * receipt. On `action: "effect"` it names what will fire; on a receipt's
125
+ * `noop`/`unobserved` rows it keeps the attribution.
126
+ */
127
+ effect?: string;
128
+ /** Why the effect fires, for `action: "effect"` (#1832). */
129
+ effectReason?: EffectFireReason;
130
+ /** Human-readable backing for `effectReason` (the digests that differ, the unresolved path). */
131
+ effectDetail?: string;
103
132
  }
104
133
 
105
134
  export interface ChangeSet {
@@ -234,13 +263,14 @@ export function buildChangeSet(env: string, input: DiffLiveInput, options?: Chan
234
263
  return { env, entries };
235
264
  }
236
265
 
237
- const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "runtime", "noop", "unobserved"];
266
+ const ACTION_ORDER: ChangeAction[] = ["create", "update", "effect", "delete", "adopt", "runtime", "noop", "unobserved"];
238
267
 
239
268
  /** Count entries per action. */
240
269
  export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
241
270
  const counts: Record<ChangeAction, number> = {
242
271
  create: 0,
243
272
  update: 0,
273
+ effect: 0,
244
274
  delete: 0,
245
275
  adopt: 0,
246
276
  runtime: 0,
@@ -257,10 +287,10 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
257
287
  * GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
258
288
  * UI as "N to add, M to change, K to delete". The format is generic — any tool
259
289
  * that emits this JSON gets the widget — and the chant plan maps onto it
260
- * directly. Only the mutating actions count: `adopt`, `runtime`, `noop` and
261
- * `unobserved` are excluded, since the widget has no column for "live but
262
- * undeclared", "expected runtime child" (#1077), "no change", or "could not
263
- * look" (#1089). The widget is therefore a floor, not a complete plan: read
290
+ * directly. Only the mutating actions count: `adopt`, `runtime`, `noop`,
291
+ * `effect` and `unobserved` are excluded, since the widget has no column for
292
+ * "live but undeclared", "expected runtime child" (#1077), "no change", "an
293
+ * effect will fire" (#1832), or "could not look" (#1089). The widget is therefore a floor, not a complete plan: read
264
294
  * the full change set when entities are unobserved or classified runtime.
265
295
  *
266
296
  * The widget label reads "Terraform" regardless of producer; that is GitLab's
@@ -292,9 +322,18 @@ export function renderChangeSet(cs: ChangeSet): string {
292
322
  ? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
293
323
  : action === "runtime"
294
324
  ? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
295
- : `\n${action.toUpperCase()}:`,
325
+ : action === "effect"
326
+ ? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
327
+ : `\n${action.toUpperCase()}:`,
296
328
  );
297
329
  for (const e of group) {
330
+ if (e.action === "effect") {
331
+ lines.push(
332
+ ` effect will fire: ${e.effect ?? e.name} — receipt ${e.name}${e.type ? ` (${e.type})` : ""}` +
333
+ `${e.effectDetail ? ` — ${e.effectDetail}` : ""}`,
334
+ );
335
+ continue;
336
+ }
298
337
  const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
299
338
  const why = e.unobservedReason
300
339
  ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
@@ -7,6 +7,8 @@ export * from "./deep-diff";
7
7
  export * from "./deep-observe";
8
8
  export * from "./observation-baseline";
9
9
  export * from "./change-set";
10
+ export * from "./unobserved-gate";
11
+ export * from "./receipt-plan";
10
12
  export * from "./affected";
11
13
  export * from "./release-ledger";
12
14
  export * from "./build-ledger";
@@ -97,3 +97,49 @@ describe("acceptDeviations", () => {
97
97
  expect(countAccepted(null)).toBe(0);
98
98
  });
99
99
  });
100
+
101
+ describe("acceptDeviations refuses effect receipts (#1833)", () => {
102
+ const now = "2026-08-24T00:00:00.000Z";
103
+
104
+ test("refuses a deviation whose type is the core receipt entityType", () => {
105
+ expect(() =>
106
+ acceptDeviations(emptyBaseline("prod"), "chant", [
107
+ { entity: "seededReceipt", type: "Chant::EffectReceipt", path: "value", value: "gone" },
108
+ ], { now }),
109
+ ).toThrow(/effect receipt "seededReceipt"/);
110
+ });
111
+
112
+ test("refuses a deviation on an entity the caller recognized as a receipt (materialized row)", () => {
113
+ expect(() =>
114
+ acceptDeviations(emptyBaseline("prod"), "aws", [
115
+ { entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
116
+ ], { now, receipts: new Set(["migratedReceipt"]) }),
117
+ ).toThrow(/effect receipt "migratedReceipt"/);
118
+ });
119
+
120
+ test("the refusal names the receipt and the effect step as sole writer, and records nothing", () => {
121
+ const before = emptyBaseline("prod");
122
+ let error: Error | undefined;
123
+ try {
124
+ acceptDeviations(before, "aws", [
125
+ { entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
126
+ { entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
127
+ ], { now, receipts: new Set(["migratedReceipt"]) });
128
+ } catch (e) {
129
+ error = e as Error;
130
+ }
131
+ expect(error).toBeDefined();
132
+ expect(error!.message).toContain('effect receipt "migratedReceipt"');
133
+ expect(error!.message).toContain("only writer");
134
+ expect(error!.message).toContain("defuse the effect");
135
+ // The whole acceptance aborts — the non-receipt row was not recorded either.
136
+ expect(before.lexicons).toEqual({});
137
+ });
138
+
139
+ test("passes: non-receipt deviations accept as before, receipts set present", () => {
140
+ const b = acceptDeviations(emptyBaseline("prod"), "aws", [
141
+ { entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
142
+ ], { now, receipts: new Set(["migratedReceipt"]) });
143
+ expect(baselineForLexicon(b, "aws").Role.accepted).toHaveLength(1);
144
+ });
145
+ });
@@ -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
+ });