@intentius/chant 0.124.24 → 0.124.25

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 (96) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/main.js +21 -7
  3. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  4. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  5. package/dist/cli/mcp/workspace-tools.js +27 -4
  6. package/dist/effect-receipt.d.ts +13 -0
  7. package/dist/effect-receipt.d.ts.map +1 -1
  8. package/dist/effect-receipt.js +19 -0
  9. package/dist/lexicon-module.d.ts +7 -1
  10. package/dist/lexicon-module.d.ts.map +1 -1
  11. package/dist/lexicon-module.js +7 -2
  12. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  13. package/dist/lifecycle/receipt-plan.js +4 -2
  14. package/dist/op/local-executor.d.ts.map +1 -1
  15. package/dist/op/local-executor.js +64 -5
  16. package/dist/op/receipt-store.d.ts +5 -0
  17. package/dist/op/receipt-store.d.ts.map +1 -1
  18. package/dist/op/receipt-store.js +4 -3
  19. package/dist/op/step-output-ref.d.ts +26 -18
  20. package/dist/op/step-output-ref.d.ts.map +1 -1
  21. package/dist/op/step-output-ref.js +86 -63
  22. package/dist/wave-runtime.mjs +213 -213
  23. package/dist/workspace/agent-cli.d.ts.map +1 -1
  24. package/dist/workspace/agent-cli.js +3 -0
  25. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  26. package/dist/workspace/conformance/writer.js +1 -1
  27. package/dist/workspace/decide.d.ts +51 -9
  28. package/dist/workspace/decide.d.ts.map +1 -1
  29. package/dist/workspace/decide.js +125 -14
  30. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  31. package/dist/workspace/lineage-upgrade.js +20 -11
  32. package/dist/workspace/point-answer.schema.json +16 -1
  33. package/dist/workspace/points-cli.d.ts +5 -3
  34. package/dist/workspace/points-cli.d.ts.map +1 -1
  35. package/dist/workspace/points-cli.js +16 -7
  36. package/dist/workspace/points-write.schema.json +39 -4
  37. package/dist/workspace/points.schema.json +42 -3
  38. package/dist/workspace/reason-codes.d.ts +2 -1
  39. package/dist/workspace/reason-codes.d.ts.map +1 -1
  40. package/dist/workspace/reason-codes.js +2 -1
  41. package/dist/workspace/records-cli.d.ts +12 -2
  42. package/dist/workspace/records-cli.d.ts.map +1 -1
  43. package/dist/workspace/records-cli.js +27 -7
  44. package/dist/workspace/records-write.d.ts.map +1 -1
  45. package/dist/workspace/records-write.js +2 -3
  46. package/dist/workspace/records.d.ts +10 -4
  47. package/dist/workspace/records.d.ts.map +1 -1
  48. package/dist/workspace/records.js +16 -3
  49. package/dist/workspace/records.schema.json +2 -1
  50. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  51. package/dist/workspace/trust/verify-cli.js +2 -1
  52. package/dist/workspace/trust/verify.d.ts +22 -0
  53. package/dist/workspace/trust/verify.d.ts.map +1 -1
  54. package/dist/workspace/trust/verify.js +94 -14
  55. package/dist/workspace/write-scope.d.ts +3 -1
  56. package/dist/workspace/write-scope.d.ts.map +1 -1
  57. package/dist/workspace/write-scope.js +11 -4
  58. package/package.json +1 -1
  59. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  60. package/src/cli/main.test.ts +10 -0
  61. package/src/cli/main.ts +21 -7
  62. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  63. package/src/cli/mcp/workspace-tools.ts +27 -4
  64. package/src/effect-receipt.ts +22 -0
  65. package/src/lexicon-module.ts +9 -2
  66. package/src/lifecycle/receipt-plan.test.ts +9 -0
  67. package/src/lifecycle/receipt-plan.ts +4 -1
  68. package/src/op/effect-step.test.ts +95 -1
  69. package/src/op/local-executor.test.ts +65 -0
  70. package/src/op/local-executor.ts +68 -5
  71. package/src/op/receipt-store.ts +13 -3
  72. package/src/op/step-output-ref.test.ts +68 -24
  73. package/src/op/step-output-ref.ts +85 -65
  74. package/src/workspace/agent-cli.ts +2 -0
  75. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  76. package/src/workspace/conformance/writer.ts +1 -1
  77. package/src/workspace/decide.test.ts +87 -3
  78. package/src/workspace/decide.ts +142 -17
  79. package/src/workspace/lineage-upgrade.test.ts +21 -0
  80. package/src/workspace/lineage-upgrade.ts +19 -11
  81. package/src/workspace/point-answer.schema.json +16 -1
  82. package/src/workspace/points-cli.ts +16 -8
  83. package/src/workspace/points-write.schema.json +39 -4
  84. package/src/workspace/points.schema.json +42 -3
  85. package/src/workspace/reason-codes.ts +2 -1
  86. package/src/workspace/record-assets.test.ts +14 -0
  87. package/src/workspace/records-cli.ts +28 -7
  88. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  89. package/src/workspace/records-write.ts +2 -3
  90. package/src/workspace/records.schema.json +2 -1
  91. package/src/workspace/records.ts +17 -4
  92. package/src/workspace/trust/trust.test.ts +105 -0
  93. package/src/workspace/trust/verify-cli.ts +2 -1
  94. package/src/workspace/trust/verify.ts +106 -13
  95. package/src/workspace/write-scope.test.ts +18 -0
  96. package/src/workspace/write-scope.ts +16 -5
@@ -66,6 +66,28 @@ export const EFFECT_RECEIPT_ENTITY_TYPE = "Chant::EffectReceipt";
66
66
  */
67
67
  export const EXISTENCE_EXPECTATION = "chant.effect-receipt:exists";
68
68
 
69
+ const OUTPUTS_SEPARATOR = "+outputs=";
70
+
71
+ /**
72
+ * A receipt expectation with the digest of the step outputs its effect read
73
+ * appended (#3948). The effect step writes this value, so the next run fires
74
+ * again when one of those outputs changed, though the declared inputs did not.
75
+ */
76
+ export function withStepOutputs(expectation: string, outputs: string): string {
77
+ return `${expectation}${OUTPUTS_SEPARATOR}${outputs}`;
78
+ }
79
+
80
+ /**
81
+ * The part of a stored receipt value that the declared inputs determine: the
82
+ * value without any step-output digest {@link withStepOutputs} appended. The
83
+ * plan and `receiptStaleness` compare this part, since step outputs exist only
84
+ * while the Op runs.
85
+ */
86
+ export function declaredExpectation(value: string): string {
87
+ const at = value.indexOf(OUTPUTS_SEPARATOR);
88
+ return at < 0 ? value : value.slice(0, at);
89
+ }
90
+
69
91
  /**
70
92
  * An effect receipt declaration — a Declarable, so discovery collects it,
71
93
  * `chant list` shows it, and (unlike a secret declaration) serialization
@@ -176,12 +176,19 @@ export function resetLexiconModules(): void {
176
176
  * Import the module a path-declared lexicon names. Returns `undefined` when
177
177
  * `name` is package-backed, so a caller falls through to its package import.
178
178
  * An import failure is thrown with the declared path in the message.
179
+ *
180
+ * `typeScript: false` imports it with plain node, without registering tsx
181
+ * (#3276): tsx's ESM hooks run on a thread of their own, which costs the
182
+ * compiled CLI about 200 MB, so a caller whose module is JavaScript says so.
179
183
  */
180
- export async function importLexiconModule(name: string): Promise<Record<string, unknown> | undefined> {
184
+ export async function importLexiconModule(
185
+ name: string,
186
+ options: { typeScript?: boolean } = {},
187
+ ): Promise<Record<string, unknown> | undefined> {
181
188
  const path = modulePaths.get(name);
182
189
  if (path === undefined) return undefined;
183
190
  try {
184
- ensureTypeScriptLoader();
191
+ if (options.typeScript !== false) ensureTypeScriptLoader();
185
192
  return esModuleNamespace((await import(path)) as Record<string, unknown>);
186
193
  } catch (err) {
187
194
  throw new Error(
@@ -12,6 +12,7 @@ import {
12
12
  EXISTENCE_EXPECTATION,
13
13
  receiptExpectation,
14
14
  resolveReceiptExpectation,
15
+ withStepOutputs,
15
16
  collectEffectReceipts,
16
17
  type EffectReceiptDeclaration,
17
18
  } from "../effect-receipt";
@@ -70,6 +71,14 @@ describe("planReceipts", () => {
70
71
  expect(entries[0].effect).toBe("seed");
71
72
  });
72
73
 
74
+ it("a value an effect step wrote with step outputs is a noop when its declared part matches (#3948)", () => {
75
+ const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { rows: 10 } });
76
+ const readings = new Map<string, ReceiptReading>([
77
+ ["seeded", { observed: true, present: true, value: withStepOutputs(receiptExpectation(r), "sha256:abc"), lexicon: "aws" }],
78
+ ]);
79
+ expect(planReceipts(receiptsOf(["seeded", r]), readings, noRefs)[0].action).toBe("noop");
80
+ });
81
+
73
82
  it("matching existence receipt is a clean noop", () => {
74
83
  const r = EffectReceipt("bootstrapped", { effect: "bootstrap", flavor: "existence" });
75
84
  const readings = new Map<string, ReceiptReading>([
@@ -34,6 +34,7 @@
34
34
  import { AttrRef } from "../attrref";
35
35
  import {
36
36
  EXISTENCE_EXPECTATION,
37
+ declaredExpectation,
37
38
  resolveReceiptExpectation,
38
39
  type EffectReceiptDeclaration,
39
40
  type ReceiptInputResolver,
@@ -198,7 +199,9 @@ export function planReceipts(
198
199
  continue;
199
200
  }
200
201
 
201
- if (reading.value === expectation) {
202
+ // A value an effect step wrote with the digest of the step outputs it read
203
+ // (#3948) is compared on its declared part: outputs exist only at run.
204
+ if (typeof reading.value === "string" && declaredExpectation(reading.value) === expectation) {
202
205
  entries.push({ ...base, action: "noop", evidence });
203
206
  continue;
204
207
  }
@@ -12,7 +12,8 @@
12
12
  * differing receipts as findings and never writes.
13
13
  */
14
14
  import { describe, test, expect, vi } from "vitest";
15
- import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION } from "../effect-receipt";
15
+ import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION, declaredExpectation, withStepOutputs } from "../effect-receipt";
16
+ import { stepOutput } from "./step-output-ref";
16
17
  import { INTRINSIC_MARKER, type Intrinsic } from "../intrinsic";
17
18
  import { effect, phase } from "./builders";
18
19
  import {
@@ -324,3 +325,96 @@ describe("runOpLocally — effect steps", () => {
324
325
  ).rejects.toThrow(/parallel phase/);
325
326
  });
326
327
  });
328
+
329
+ // ── Step outputs inside an effect (#3948) ───────────────────────────────────
330
+
331
+ describe("runOpLocally — an effect's nested steps read step outputs (#3948)", () => {
332
+ function refOp(): OpConfig {
333
+ return {
334
+ name: "seed-op",
335
+ overview: "",
336
+ phases: [
337
+ phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }]),
338
+ phase("Seed", [effect(seeded, [step("runSeed", { file: stepOutput("pick", "file") })])]),
339
+ ],
340
+ };
341
+ }
342
+
343
+ test("the resolved value reaches the nested step, and the receipt holds its digest", async () => {
344
+ const { store, values } = memStore();
345
+ const received: unknown[] = [];
346
+ let file = "a.sql";
347
+ const activities = activityMap(store, {
348
+ pick: async () => ({ file }),
349
+ runSeed: async (args) => void received.push(args),
350
+ });
351
+ const first = await runOpLocally(refOp(), activities, PROFILES);
352
+ expect(first.status).toBe("ok");
353
+ expect(received).toEqual([{ file: "a.sql" }]);
354
+ const written = values.get("seeded")!;
355
+ expect(written).not.toBe(receiptExpectation(seeded));
356
+ expect(declaredExpectation(written)).toBe(receiptExpectation(seeded));
357
+
358
+ // The same value: the receipt matches and the effect is skipped.
359
+ const second = await runOpLocally(refOp(), activities, PROFILES);
360
+ expect(received).toHaveLength(1);
361
+ expect(second.records.find((r) => r.fn === "receiptRead")?.outcome).toEqual({ name: "EffectApplied", value: true });
362
+
363
+ // A changed value fires the effect again, though the declared inputs did not change.
364
+ file = "b.sql";
365
+ await runOpLocally(refOp(), activities, PROFILES);
366
+ expect(received).toEqual([{ file: "a.sql" }, { file: "b.sql" }]);
367
+ expect(values.get("seeded")).not.toBe(written);
368
+ });
369
+
370
+ test("a receipt written before the effect read outputs re-fires the effect once", async () => {
371
+ const { store, values } = memStore({ seeded: receiptExpectation(seeded) });
372
+ const ran = vi.fn();
373
+ const activities = activityMap(store, { pick: async () => ({ file: "a.sql" }), runSeed: async () => { ran(); } });
374
+ await runOpLocally(refOp(), activities, PROFILES);
375
+ expect(ran).toHaveBeenCalledTimes(1);
376
+ expect(declaredExpectation(values.get("seeded")!)).toBe(receiptExpectation(seeded));
377
+ });
378
+
379
+ test("an existence receipt takes no outputs: it says the effect ran once", async () => {
380
+ const booted = EffectReceipt("booted", { effect: "bootstrap", flavor: "existence" });
381
+ const config: OpConfig = {
382
+ name: "boot-op",
383
+ overview: "",
384
+ phases: [
385
+ phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }]),
386
+ phase("Boot", [effect(booted, [step("boot", { file: stepOutput("pick", "file") })])]),
387
+ ],
388
+ };
389
+ const { store, values } = memStore();
390
+ const activities = activityMap(store, { pick: async () => ({ file: "a.sql" }), boot: async () => {} });
391
+ await runOpLocally(config, activities, PROFILES);
392
+ expect(values.get("booted")).toBe(EXISTENCE_EXPECTATION);
393
+ });
394
+
395
+ test("an effect in onFailure whose source step did not complete is skipped whole (A6)", async () => {
396
+ const config: OpConfig = {
397
+ name: "seed-op",
398
+ overview: "",
399
+ phases: [phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }])],
400
+ onFailure: [phase("Undo", [effect(seeded, [step("unseed", { file: stepOutput("pick", "file") })])])],
401
+ };
402
+ const { store, writes } = memStore();
403
+ const ran = vi.fn();
404
+ const activities = activityMap(store, { pick: async () => { throw new Error("boom"); }, unseed: async () => { ran(); } });
405
+ const failure = (await runOpLocally(config, activities, PROFILES).catch((e) => e)) as OpRunFailure;
406
+ expect(failure).toBeInstanceOf(OpRunFailure);
407
+ expect(ran).not.toHaveBeenCalled();
408
+ expect(writes).toEqual([]);
409
+ const skipped = failure.result.records.find((r) => r.fn === "effect:seeded");
410
+ expect(skipped?.status).toBe("skipped");
411
+ expect(skipped?.refusal).toMatch(/step "pick" did not complete/);
412
+ });
413
+
414
+ test("receiptStaleness compares the declared part of a value written with outputs", async () => {
415
+ const expected = receiptExpectation(seeded);
416
+ const { store } = memStore({ seeded: withStepOutputs(expected, "sha256:abc") });
417
+ const { receiptStaleness } = receiptActivities(store);
418
+ expect(await receiptStaleness({ receipts: [receiptCheckInput(seeded)] })).toEqual({ stale: false, findings: [] });
419
+ });
420
+ });
@@ -459,6 +459,71 @@ describe("runOpLocally — gate as fact (#2119)", () => {
459
459
  });
460
460
  });
461
461
 
462
+ describe("runOpLocally — step-output references in onFailure (#3948)", () => {
463
+ const failing: ActivityFn = async () => { throw new Error("boom"); };
464
+
465
+ test("a compensation step receives the value a main-phase step returned", async () => {
466
+ const received: unknown[] = [];
467
+ const activities = new Map<string, ActivityFn>([
468
+ ["deploy", async () => ({ previous: "v1" })],
469
+ ["migrate", failing],
470
+ ["restore", async (args) => { received.push(args); }],
471
+ ]);
472
+ const config = op({
473
+ phases: [
474
+ { name: "Deploy", steps: [{ kind: "activity", fn: "deploy", id: "deploy" }] },
475
+ { name: "Migrate", steps: [{ kind: "activity", fn: "migrate" }] },
476
+ ],
477
+ onFailure: [{ name: "Rollback", steps: [{ kind: "activity", fn: "restore", args: { revision: stepOutput("deploy", "previous") } }] }],
478
+ });
479
+ const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
480
+ expect(err).toBeInstanceOf(OpRunFailure);
481
+ expect(received).toEqual([{ revision: "v1" }]);
482
+ });
483
+
484
+ test("a compensation step whose source step did not complete is skipped, and the record says why (A6)", async () => {
485
+ const ran: string[] = [];
486
+ const activities = new Map<string, ActivityFn>([
487
+ ["deploy", failing],
488
+ ["restore", async () => void ran.push("restore")],
489
+ ["notify", async () => void ran.push("notify")],
490
+ ]);
491
+ const config = op({
492
+ phases: [{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy", id: "deploy" }] }],
493
+ onFailure: [{ name: "Rollback", steps: [
494
+ { kind: "activity", fn: "restore", args: { revision: stepOutput("deploy", "previous") } },
495
+ { kind: "activity", fn: "notify" },
496
+ ] }],
497
+ });
498
+ const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
499
+ expect(err).toBeInstanceOf(OpRunFailure);
500
+ // The skip is not a failure of compensation: the next step still runs.
501
+ expect(ran).toEqual(["notify"]);
502
+ const restore = err.result.records.find((r: { fn: string }) => r.fn === "restore");
503
+ expect(restore.status).toBe("skipped");
504
+ expect(restore.refusal).toMatch(/step "deploy" did not complete/);
505
+ });
506
+
507
+ test("the skip applies in a parallel compensation phase too", async () => {
508
+ const ran: string[] = [];
509
+ const activities = new Map<string, ActivityFn>([
510
+ ["deploy", failing],
511
+ ["restore", async () => void ran.push("restore")],
512
+ ["notify", async () => void ran.push("notify")],
513
+ ]);
514
+ const config = op({
515
+ phases: [{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy", id: "deploy" }] }],
516
+ onFailure: [{ name: "Rollback", parallel: true, steps: [
517
+ { kind: "activity", fn: "restore", args: { revision: stepOutput("deploy") } },
518
+ { kind: "activity", fn: "notify" },
519
+ ] }],
520
+ });
521
+ const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
522
+ expect(ran).toEqual(["notify"]);
523
+ expect(err.result.records.find((r: { fn: string }) => r.fn === "restore").status).toBe("skipped");
524
+ });
525
+ });
526
+
462
527
  describe("runOpLocally — step-output references (#1290)", () => {
463
528
  test("a later step receives the producer's resolved value, not the placeholder", async () => {
464
529
  const received: unknown[] = [];
@@ -23,7 +23,9 @@ import { outcomeAttributesOf } from "./types";
23
23
  import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, EffectStep, StepDefinition } from "./types";
24
24
  import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
25
25
  import type { ReceiptReadResult } from "./receipt-store";
26
- import { isStepOutputRef } from "./step-output-ref";
26
+ import { collectStepOutputRefs, isStepOutputRef } from "./step-output-ref";
27
+ import { canonicalJson } from "../effect-receipt";
28
+ import { contentDigest } from "../content-digest";
27
29
  import { parseDuration } from "./duration";
28
30
  import { stepLabel, withRunConsole, withStepConsole } from "./step-console";
29
31
  import { stepTimeoutProblem } from "./activity-profiles";
@@ -278,6 +280,48 @@ function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string,
278
280
  return value;
279
281
  }
280
282
 
283
+ /** The activity steps a step runs: itself, or an effect's nested ones. */
284
+ function activitiesOf(step: ActivityStep | EffectStep): ActivityStep[] {
285
+ return isEffect(step) ? step.steps.filter(isActivity) : [step];
286
+ }
287
+
288
+ /**
289
+ * The first step a compensation step references that has not completed in
290
+ * this run (#3948 decision A6), or `undefined` when every one did. A failure
291
+ * can stop the run before the step that a rollback reads, and calling the
292
+ * rollback with `undefined` in place of that value would undo the wrong
293
+ * thing, or nothing, while reporting `ok`.
294
+ */
295
+ function unfinishedSource(step: ActivityStep | EffectStep, resultsById: ReadonlyMap<string, unknown>): string | undefined {
296
+ for (const activity of activitiesOf(step)) {
297
+ const missing = collectStepOutputRefs(activity.args).find((ref) => !resultsById.has(ref.step));
298
+ if (missing) return missing.step;
299
+ }
300
+ return undefined;
301
+ }
302
+
303
+ /** The skipped record of a compensation step whose source did not complete. */
304
+ function unfinishedSourceRecord(step: ActivityStep | EffectStep, phaseName: string, source: string): StepRecord {
305
+ const record = isEffect(step) ? skippedRecord(phaseName, `effect:${step.receipt.name}`) : skippedRecord(phaseName, step.fn, step.args);
306
+ return { ...record, refusal: `step "${source}" did not complete in this run, so this compensation step, which reads its output, was skipped` };
307
+ }
308
+
309
+ /**
310
+ * The digest of the step outputs an effect's nested steps read (#3948), or
311
+ * `undefined` when they read none. The receipt store folds it into the
312
+ * expectation, so a changed value re-fires the effect instead of the receipt
313
+ * matching on the declared inputs alone. An existence receipt says only that
314
+ * the effect ran once, so it takes none.
315
+ */
316
+ function effectOutputsDigest(step: EffectStep, resultsById: ReadonlyMap<string, unknown>): string | undefined {
317
+ if (step.receipt.flavor === "existence") return undefined;
318
+ const read = activitiesOf(step).flatMap((a) =>
319
+ collectStepOutputRefs(a.args).map((ref) => ({ step: ref.step, path: ref.path ?? "", value: resolveStepOutputRefs(ref, resultsById) })),
320
+ );
321
+ if (read.length === 0) return undefined;
322
+ return contentDigest(canonicalJson(read));
323
+ }
324
+
281
325
  /**
282
326
  * Find the first `policyGate` step anywhere in the Op (phases + `onFailure`,
283
327
  * including steps nested inside effect steps), if any.
@@ -464,13 +508,14 @@ async function runStep(
464
508
  /** The step data the executor synthesizes to read a receipt through the store
465
509
  * activities (`receiptRead`/`receiptWrite` — provided by the receipt row's
466
510
  * lexicon, #1835, or a mock store in tests via `receiptActivities`). */
467
- function receiptReadStep(step: EffectStep): ActivityStep {
511
+ function receiptReadStep(step: EffectStep, outputs?: string): ActivityStep {
468
512
  return {
469
513
  kind: "activity",
470
514
  fn: "receiptRead",
471
515
  args: {
472
516
  receipt: step.receipt,
473
517
  ...(step.expectation !== undefined ? { expectation: step.expectation } : {}),
518
+ ...(outputs !== undefined ? { outputs } : {}),
474
519
  },
475
520
  profile: "fastIdempotent",
476
521
  outcomeAttribute: { name: "EffectApplied", from: "applied" },
@@ -496,6 +541,8 @@ interface GateContext {
496
541
  passed?: PassedGate[];
497
542
  /** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
498
543
  work?: RunWorkLease;
544
+ /** Set in `onFailure` phases (#3948): a step whose referenced step did not complete is skipped. */
545
+ compensating?: boolean;
499
546
  /** How a gate that names a decision point asks it (#3170). */
500
547
  points?: GatePointAsker;
501
548
  /** The environment the run was started for, a point gate's `gate.env` input. */
@@ -790,7 +837,15 @@ async function runEffectStep(
790
837
  }> {
791
838
  const records: StepRecord[] = [];
792
839
 
793
- const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
840
+ let outputs: string | undefined;
841
+ try {
842
+ outputs = effectOutputsDigest(step, resultsById);
843
+ } catch (err) {
844
+ // A resolved value JSON cannot hold (canonicalJson refuses it) would be a wrong expectation.
845
+ pushRecord(records, gates, { phase: phaseName, fn: `effect:${step.receipt.name}`, args: {}, status: "fail", durationMs: 0, error: errMessage(err) });
846
+ return { records, failed: true };
847
+ }
848
+ const read = await runStep(receiptReadStep(step, outputs), phaseName, activities, profiles, resultsById, signal);
794
849
  pushRecord(records, gates, read.record);
795
850
  if (read.record.status === "fail") return { records, failed: true };
796
851
 
@@ -950,7 +1005,10 @@ async function runPhase(
950
1005
  }
951
1006
  }
952
1007
  const steps = phase.steps.filter(isActivity);
953
- const settled = await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, resultsById, signal)));
1008
+ const settled = await Promise.all(steps.map((s) => {
1009
+ const source = gates.compensating ? unfinishedSource(s, resultsById) : undefined;
1010
+ return source ? { record: unfinishedSourceRecord(s, phase.name, source) } as RanStep : runStep(s, phase.name, activities, profiles, resultsById, signal);
1011
+ }));
954
1012
  const ran = settled.map((r) => r.record);
955
1013
  for (const r of ran) gates.onRecord?.(r);
956
1014
  const records = gateRecords.concat(ran);
@@ -1011,6 +1069,11 @@ async function runPhase(
1011
1069
  }
1012
1070
  continue;
1013
1071
  }
1072
+ const source = gates.compensating ? unfinishedSource(step, resultsById) : undefined;
1073
+ if (source) {
1074
+ pushRecord(records, gates, unfinishedSourceRecord(step, phase.name, source));
1075
+ continue;
1076
+ }
1014
1077
  if (isEffect(step)) {
1015
1078
  const { records: effRecords, failed, pending, pushed, pushWarning, point } = await runEffectStep(
1016
1079
  step, phase.name, activities, profiles, resultsById, gates, signal,
@@ -1444,7 +1507,7 @@ async function runOpInTurn(
1444
1507
  // Compensation: run onFailure phases in reverse order (best-effort). Skipped
1445
1508
  // on abort (Ctrl-C) — the user asked to stop, so don't start new work.
1446
1509
  // It runs whatever the work lease did, so it takes no work boundary.
1447
- const compensation: GateContext = { ...gates, work: undefined };
1510
+ const compensation: GateContext = { ...gates, work: undefined, compensating: true };
1448
1511
  if (!signal?.aborted) {
1449
1512
  for (const phase of [...(config.onFailure ?? [])].reverse()) {
1450
1513
  try {
@@ -34,6 +34,8 @@ import {
34
34
  referenceInputPaths,
35
35
  type EffectReceiptDeclaration,
36
36
  type EffectReceiptFlavor,
37
+ declaredExpectation,
38
+ withStepOutputs,
37
39
  } from "../effect-receipt";
38
40
 
39
41
  /**
@@ -101,7 +103,14 @@ export function receiptCheckInput(receipt: EffectReceiptDeclaration): ReceiptChe
101
103
  // ── Activity contracts ────────────────────────────────────────────────────────
102
104
 
103
105
  /** Args of the `receiptRead` activity, as the effect step's codegen emits them. */
104
- export interface ReceiptReadArgs extends ReceiptCheckInput {}
106
+ export interface ReceiptReadArgs extends ReceiptCheckInput {
107
+ /**
108
+ * The digest of the step outputs the effect's nested steps read (#3948),
109
+ * when they read any. Folded into the expectation by {@link withStepOutputs}.
110
+ */
111
+ outputs?: string;
112
+ }
113
+
105
114
 
106
115
  /** Result of the `receiptRead` activity. `current` is `null` (not `undefined`)
107
116
  * for an absent receipt so the value survives JSON transport. */
@@ -183,7 +192,8 @@ export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOpt
183
192
 
184
193
  return {
185
194
  async receiptRead(args: ReceiptReadArgs): Promise<ReceiptReadResult> {
186
- const expectation = await expectationOf(args);
195
+ const declared = await expectationOf(args);
196
+ const expectation = args.outputs !== undefined ? withStepOutputs(declared, args.outputs) : declared;
187
197
  const current = await store.read(args.receipt);
188
198
  return { current: current ?? null, expectation, applied: current === expectation };
189
199
  },
@@ -201,7 +211,7 @@ export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOpt
201
211
  const current = await store.read(input.receipt);
202
212
  if (current === undefined) {
203
213
  findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "absent", expected });
204
- } else if (current !== expected) {
214
+ } else if (declaredExpectation(current) !== expected) {
205
215
  findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "differs", expected, current });
206
216
  }
207
217
  }
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  import { activity, phase, effect } from "./builders";
11
11
  import type { OpConfig } from "./types";
12
12
  import { activityContract, type ActivityContract } from "./activity-contract";
13
- import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs } from "./step-output-ref";
13
+ import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope } from "./step-output-ref";
14
14
  import { EffectReceipt } from "../effect-receipt";
15
15
 
16
16
  const lifecycleDiffContract = activityContract(
@@ -248,39 +248,83 @@ describe("validateStepOutputRefs() — structural coverage", () => {
248
248
  expect(issues.some((i) => i.message.includes("declares no return schema"))).toBe(true);
249
249
  });
250
250
 
251
- it("flags a reference authored inside onFailure", () => {
251
+ it("reports every issue across multiple refs, not just the first", () => {
252
+ const apply1 = activity("applyStacks", { stacks: stepOutput("nope1", "x") });
253
+ const apply2 = activity("applyStacks", { stacks: stepOutput("nope2", "x") });
254
+ const config = opWith({ phases: [phase("P", [apply1, apply2])] });
255
+ const issues = validateStepOutputRefs(config, new Map([["applyStacks", applyStacksContract]]));
256
+ expect(issues.some((i) => i.message.includes("nope1"))).toBe(true);
257
+ expect(issues.some((i) => i.message.includes("nope2"))).toBe(true);
258
+ });
259
+ });
260
+
261
+ // ── onFailure and effect steps (#3948) ───────────────────────────────────────
262
+
263
+ describe("validateStepOutputRefs() — onFailure and effect steps (#3948)", () => {
264
+ const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
265
+ const contracts = new Map<string, ActivityContract>([
266
+ ["lifecycleDiff", lifecycleDiffContract],
267
+ ["applyStacks", applyStacksContract],
268
+ ]);
269
+
270
+ it("an onFailure step may reference any main-phase step", () => {
252
271
  const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
272
+ const apply = activity("applyStacks", { stacks: ["a"] });
253
273
  const rollback = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
274
+ const config = opWith({ phases: [phase("Diff", [diff]), phase("Apply", [apply])], onFailure: [phase("Rollback", [rollback])] });
275
+ expect(validateStepOutputRefs(config, contracts)).toEqual([]);
276
+ });
277
+
278
+ it("an onFailure step's reference is still checked against the producer's contract", () => {
279
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
280
+ const rollback = activity("applyStacks", { stacks: stepOutput("diff", "nope") });
254
281
  const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Rollback", [rollback])] });
255
- const contracts = new Map<string, ActivityContract>([
256
- ["lifecycleDiff", lifecycleDiffContract],
257
- ["applyStacks", applyStacksContract],
258
- ]);
259
282
  const issues = validateStepOutputRefs(config, contracts);
260
- expect(issues.some((i) => i.message.includes("onFailure compensation"))).toBe(true);
283
+ expect(issues).toHaveLength(1);
284
+ expect(issues[0].phase).toBe("Rollback");
285
+ expect(issues[0].message).toMatch(/path "nope", which does not exist/);
286
+ });
287
+
288
+ it("an onFailure step cannot reference another onFailure step", () => {
289
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "undoDiff" });
290
+ const rollback = activity("applyStacks", { stacks: stepOutput("undoDiff", "driftedStacks") });
291
+ const config = opWith({ phases: [phase("P", [activity("applyStacks", { stacks: [] })])], onFailure: [phase("Rollback", [diff, rollback])] });
292
+ expect(validateStepOutputRefScope(config)[0].message).toMatch(/unknown step id "undoDiff"/);
261
293
  });
262
294
 
263
- it("flags a reference authored inside an effect step's nested steps", () => {
264
- const receipt = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
295
+ it("a step inside an effect may reference a step that runs before the effect", () => {
265
296
  const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
266
297
  const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
267
- const effectStep = effect(receipt, [nested]);
268
- const config = opWith({ phases: [phase("P", [diff, effectStep])] });
269
- const contracts = new Map<string, ActivityContract>([
270
- ["lifecycleDiff", lifecycleDiffContract],
271
- ["applyStacks", applyStacksContract],
272
- ]);
273
- const issues = validateStepOutputRefs(config, contracts);
274
- expect(issues.some((i) => i.message.includes("nested inside an effect step"))).toBe(true);
298
+ const config = opWith({ phases: [phase("P", [diff, effect(seeded, [nested])])] });
299
+ expect(validateStepOutputRefs(config, contracts)).toEqual([]);
275
300
  });
276
301
 
277
- it("reports every issue across multiple refs, not just the first", () => {
278
- const apply1 = activity("applyStacks", { stacks: stepOutput("nope1", "x") });
279
- const apply2 = activity("applyStacks", { stacks: stepOutput("nope2", "x") });
280
- const config = opWith({ phases: [phase("P", [apply1, apply2])] });
281
- const issues = validateStepOutputRefs(config, new Map([["applyStacks", applyStacksContract]]));
282
- expect(issues.some((i) => i.message.includes("nope1"))).toBe(true);
283
- expect(issues.some((i) => i.message.includes("nope2"))).toBe(true);
302
+ it("a step inside an effect in onFailure may reference a main-phase step", () => {
303
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
304
+ const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
305
+ const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Undo", [effect(seeded, [nested])])] });
306
+ expect(validateStepOutputRefs(config, contracts)).toEqual([]);
307
+ });
308
+
309
+ it("a step inside an effect cannot reference a step after the effect", () => {
310
+ const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
311
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
312
+ const config = opWith({ phases: [phase("P", [effect(seeded, [nested]), diff])] });
313
+ expect(validateStepOutputRefScope(config)[0].message).toMatch(/step "diff" does not run before this step/);
314
+ });
315
+
316
+ it("nothing can reference a step nested inside an effect (decision A7)", () => {
317
+ const inner = activity("lifecycleDiff", { env: "prod" }, { id: "inner" });
318
+ const sibling = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
319
+ const later = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
320
+ const rollback = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
321
+ const config = opWith({
322
+ phases: [phase("P", [effect(seeded, [inner, sibling]), later])],
323
+ onFailure: [phase("Rollback", [rollback])],
324
+ });
325
+ const issues = validateStepOutputRefScope(config);
326
+ expect(issues).toHaveLength(3);
327
+ for (const issue of issues) expect(issue.message).toMatch(/"inner", which is nested inside an effect step/);
284
328
  });
285
329
  });
286
330