@intentius/chant 0.124.24 → 0.124.26

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 (195) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/main.js +21 -7
  9. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/mcp/workspace-tools.js +27 -4
  12. package/dist/effect-receipt.d.ts +13 -0
  13. package/dist/effect-receipt.d.ts.map +1 -1
  14. package/dist/effect-receipt.js +19 -0
  15. package/dist/gate-decision.d.ts +1 -1
  16. package/dist/gate-decision.d.ts.map +1 -1
  17. package/dist/gate-decision.js +1 -1
  18. package/dist/lexicon-module.d.ts +7 -1
  19. package/dist/lexicon-module.d.ts.map +1 -1
  20. package/dist/lexicon-module.js +7 -2
  21. package/dist/lifecycle/gate-audit.d.ts +7 -1
  22. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  23. package/dist/lifecycle/gate-audit.js +40 -3
  24. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  25. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  26. package/dist/lifecycle/gate-checkpoint.js +103 -0
  27. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  28. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.js +18 -0
  30. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  31. package/dist/lifecycle/receipt-plan.js +4 -2
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/run-ledger.js +2 -0
  35. package/dist/lint/rules/op/index.d.ts +6 -2
  36. package/dist/lint/rules/op/index.d.ts.map +1 -1
  37. package/dist/lint/rules/op/index.js +7 -3
  38. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  39. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  40. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  41. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  42. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  43. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  44. package/dist/op/activity-contract.d.ts +14 -0
  45. package/dist/op/activity-contract.d.ts.map +1 -1
  46. package/dist/op/activity-contract.js +16 -0
  47. package/dist/op/builders.d.ts +7 -0
  48. package/dist/op/builders.d.ts.map +1 -1
  49. package/dist/op/builders.js +13 -0
  50. package/dist/op/gate-alias.d.ts +57 -0
  51. package/dist/op/gate-alias.d.ts.map +1 -0
  52. package/dist/op/gate-alias.js +127 -0
  53. package/dist/op/gate-checkpoint.d.ts +123 -0
  54. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  55. package/dist/op/gate-checkpoint.js +280 -0
  56. package/dist/op/gate.d.ts +54 -2
  57. package/dist/op/gate.d.ts.map +1 -1
  58. package/dist/op/gate.js +142 -1
  59. package/dist/op/index.d.ts +4 -1
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/index.js +2 -1
  62. package/dist/op/local-executor.d.ts +57 -2
  63. package/dist/op/local-executor.d.ts.map +1 -1
  64. package/dist/op/local-executor.js +200 -11
  65. package/dist/op/local-output.d.ts.map +1 -1
  66. package/dist/op/local-output.js +20 -2
  67. package/dist/op/op-ir.d.ts +4 -0
  68. package/dist/op/op-ir.d.ts.map +1 -1
  69. package/dist/op/op-ir.js +4 -0
  70. package/dist/op/receipt-store.d.ts +5 -0
  71. package/dist/op/receipt-store.d.ts.map +1 -1
  72. package/dist/op/receipt-store.js +4 -3
  73. package/dist/op/runtime.d.ts +15 -0
  74. package/dist/op/runtime.d.ts.map +1 -1
  75. package/dist/op/runtimes/local.d.ts.map +1 -1
  76. package/dist/op/runtimes/local.js +5 -0
  77. package/dist/op/step-output-ref.d.ts +26 -18
  78. package/dist/op/step-output-ref.d.ts.map +1 -1
  79. package/dist/op/step-output-ref.js +86 -63
  80. package/dist/op/types.d.ts +31 -0
  81. package/dist/op/types.d.ts.map +1 -1
  82. package/dist/wave-runtime.mjs +223 -221
  83. package/dist/workspace/agent-cli.d.ts.map +1 -1
  84. package/dist/workspace/agent-cli.js +3 -0
  85. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  86. package/dist/workspace/conformance/writer.js +1 -1
  87. package/dist/workspace/decide.d.ts +51 -9
  88. package/dist/workspace/decide.d.ts.map +1 -1
  89. package/dist/workspace/decide.js +125 -14
  90. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  91. package/dist/workspace/lineage-upgrade.js +20 -11
  92. package/dist/workspace/point-answer.schema.json +16 -1
  93. package/dist/workspace/points-cli.d.ts +5 -3
  94. package/dist/workspace/points-cli.d.ts.map +1 -1
  95. package/dist/workspace/points-cli.js +16 -7
  96. package/dist/workspace/points-write.schema.json +39 -4
  97. package/dist/workspace/points.schema.json +42 -3
  98. package/dist/workspace/reason-codes.d.ts +2 -1
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +2 -1
  101. package/dist/workspace/records-cli.d.ts +12 -2
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-cli.js +27 -7
  104. package/dist/workspace/records-write.d.ts.map +1 -1
  105. package/dist/workspace/records-write.js +2 -3
  106. package/dist/workspace/records.d.ts +10 -4
  107. package/dist/workspace/records.d.ts.map +1 -1
  108. package/dist/workspace/records.js +16 -3
  109. package/dist/workspace/records.schema.json +2 -1
  110. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  111. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  112. package/dist/workspace/trust/gate-seal.js +41 -2
  113. package/dist/workspace/trust/seal.d.ts +1 -1
  114. package/dist/workspace/trust/seal.d.ts.map +1 -1
  115. package/dist/workspace/trust/seal.js +1 -1
  116. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  117. package/dist/workspace/trust/verify-cli.js +2 -1
  118. package/dist/workspace/trust/verify.d.ts +22 -0
  119. package/dist/workspace/trust/verify.d.ts.map +1 -1
  120. package/dist/workspace/trust/verify.js +94 -14
  121. package/dist/workspace/write-scope.d.ts +3 -1
  122. package/dist/workspace/write-scope.d.ts.map +1 -1
  123. package/dist/workspace/write-scope.js +11 -4
  124. package/package.json +1 -1
  125. package/src/cli/commands/build.ts +9 -2
  126. package/src/cli/commands/lint.ts +4 -2
  127. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  128. package/src/cli/handlers/approve.ts +46 -4
  129. package/src/cli/handlers/operator.test.ts +91 -0
  130. package/src/cli/main.test.ts +10 -0
  131. package/src/cli/main.ts +21 -7
  132. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  133. package/src/cli/mcp/workspace-tools.ts +27 -4
  134. package/src/effect-receipt.ts +22 -0
  135. package/src/gate-decision.ts +2 -0
  136. package/src/lexicon-module.ts +9 -2
  137. package/src/lifecycle/gate-audit.ts +41 -4
  138. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  139. package/src/lifecycle/gate-checkpoint.ts +133 -0
  140. package/src/lifecycle/gate-ledger.ts +61 -0
  141. package/src/lifecycle/receipt-plan.test.ts +9 -0
  142. package/src/lifecycle/receipt-plan.ts +4 -1
  143. package/src/lifecycle/run-ledger.ts +4 -0
  144. package/src/lint/rules/op/index.ts +9 -3
  145. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  146. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  147. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  148. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  149. package/src/op/activity-contract.ts +22 -1
  150. package/src/op/builders.ts +21 -1
  151. package/src/op/effect-step.test.ts +95 -1
  152. package/src/op/gate-alias.e2e.test.ts +78 -0
  153. package/src/op/gate-alias.test.ts +350 -0
  154. package/src/op/gate-alias.ts +137 -0
  155. package/src/op/gate-checkpoint.test.ts +284 -0
  156. package/src/op/gate-checkpoint.ts +322 -0
  157. package/src/op/gate.ts +198 -4
  158. package/src/op/index.ts +4 -1
  159. package/src/op/local-executor.test.ts +65 -0
  160. package/src/op/local-executor.ts +248 -12
  161. package/src/op/local-output.ts +18 -2
  162. package/src/op/op-ir.test.ts +25 -0
  163. package/src/op/op-ir.ts +8 -0
  164. package/src/op/receipt-store.ts +13 -3
  165. package/src/op/runtime.ts +8 -0
  166. package/src/op/runtimes/local.ts +5 -0
  167. package/src/op/step-output-ref.test.ts +68 -24
  168. package/src/op/step-output-ref.ts +85 -65
  169. package/src/op/types.ts +32 -0
  170. package/src/workspace/agent-cli.ts +2 -0
  171. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  172. package/src/workspace/conformance/writer.ts +1 -1
  173. package/src/workspace/decide.test.ts +87 -3
  174. package/src/workspace/decide.ts +142 -17
  175. package/src/workspace/lineage-upgrade.test.ts +21 -0
  176. package/src/workspace/lineage-upgrade.ts +19 -11
  177. package/src/workspace/point-answer.schema.json +16 -1
  178. package/src/workspace/points-cli.ts +16 -8
  179. package/src/workspace/points-write.schema.json +39 -4
  180. package/src/workspace/points.schema.json +42 -3
  181. package/src/workspace/reason-codes.ts +2 -1
  182. package/src/workspace/record-assets.test.ts +14 -0
  183. package/src/workspace/records-cli.ts +28 -7
  184. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  185. package/src/workspace/records-write.ts +2 -3
  186. package/src/workspace/records.schema.json +2 -1
  187. package/src/workspace/records.ts +17 -4
  188. package/src/workspace/trust/gate-seal.test.ts +29 -2
  189. package/src/workspace/trust/gate-seal.ts +46 -2
  190. package/src/workspace/trust/seal.ts +1 -1
  191. package/src/workspace/trust/trust.test.ts +105 -0
  192. package/src/workspace/trust/verify-cli.ts +2 -1
  193. package/src/workspace/trust/verify.ts +106 -13
  194. package/src/workspace/write-scope.test.ts +18 -0
  195. package/src/workspace/write-scope.ts +16 -5
@@ -0,0 +1,89 @@
1
+ /**
2
+ * OPS017: a gate's aliases (#3949) can stand. An alias admits approvals of
3
+ * the plan a run planned, so the gate must bind a `plan`, and a gate that
4
+ * asks a decision point takes none. An alias must not name a gate that still
5
+ * exists in the build, or one approval would answer two gates, and two gates
6
+ * must not claim one alias. When the workspace's `identity.gates` names an
7
+ * alias, the new gate must be named too, with the alias's class, or
8
+ * approvals that needed a seal would count without one.
9
+ *
10
+ * The identity rules come from the caller, as SYS010's decide backends do:
11
+ * `chant build` and `chant lint` read them with
12
+ * {@link workspaceIdentityGates}, which loads workspace code only when an Op
13
+ * has aliases and the project sits in a workspace. Without them that part
14
+ * is skipped.
15
+ */
16
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
17
+ import type { OpConfig } from "../../../op";
18
+ import { gateStepsOf, validateGateAliases } from "../../../op/gate-alias";
19
+ import { findWorkspaceRoot } from "../../../project-root";
20
+ import { isOpEntity } from "./support";
21
+
22
+ /** The Op configs among a build's entities, each with its entity key and lexicon. */
23
+ function opsOf(entities: Iterable<[string, unknown]>): Array<{ entityKey: string; lexicon?: string; config: OpConfig }> {
24
+ const ops: Array<{ entityKey: string; lexicon?: string; config: OpConfig }> = [];
25
+ for (const [entityKey, entity] of entities) {
26
+ if (!isOpEntity(entity as never)) continue;
27
+ const rec = entity as Record<string, unknown>;
28
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
29
+ if (typeof props.name !== "string" || !Array.isArray(props.phases)) continue;
30
+ ops.push({ entityKey, ...(typeof rec.lexicon === "string" ? { lexicon: rec.lexicon } : {}), config: props });
31
+ }
32
+ return ops;
33
+ }
34
+
35
+ const hasAliases = (ops: ReadonlyArray<{ config: OpConfig }>): boolean =>
36
+ ops.some(({ config }) => gateStepsOf(config).some(({ step }) => step.aliases !== undefined));
37
+
38
+ /**
39
+ * The gates the workspace around `projectDir` names under `identity.gates`,
40
+ * each with its class (null for any signer), for OPS017. Undefined when no
41
+ * Op among `entities` has aliases, outside a workspace, or when the
42
+ * declaration can't be read, which `chant workspace check` reports. The
43
+ * workspace modules load only past the first two checks, so a level-0
44
+ * build loads none.
45
+ */
46
+ export async function workspaceIdentityGates(
47
+ projectDir: string,
48
+ entities: Iterable<[string, unknown]>,
49
+ ): Promise<Map<string, string | null> | undefined> {
50
+ if (!hasAliases(opsOf(entities))) return undefined;
51
+ const found = findWorkspaceRoot(projectDir);
52
+ if (!found) return undefined;
53
+ try {
54
+ const { readDeclaration } = await import("../../../workspace/declaration");
55
+ const { workingTree } = await import("../../../workspace/tree");
56
+ const gates = readDeclaration(workingTree(found.dir)).identity?.gates ?? {};
57
+ return new Map(Object.values(gates).map((g) => [g.gate, g.class]));
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ /** OPS017, judging `identity.gates` by `identityGates` when given. */
64
+ export function ops017Check(identityGates?: ReadonlyMap<string, string | null>): PostSynthCheck {
65
+ return {
66
+ id: "OPS017",
67
+ description:
68
+ "A gate's aliases need a plan and no decision point, must not name a gate that still exists or another gate's alias, and keep any identity.gates rule an alias had",
69
+
70
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
71
+ const ops = opsOf(ctx.entities);
72
+ if (!hasAliases(ops)) return [];
73
+ const byName = new Map(ops.map((o) => [o.config.name, o]));
74
+ return validateGateAliases(ops.map((o) => o.config), identityGates).map((issue) => {
75
+ const op = byName.get(issue.opName);
76
+ return {
77
+ checkId: "OPS017",
78
+ severity: "error",
79
+ message: `Op "${issue.opName}", phase "${issue.phase}", gate:${issue.gate}: ${issue.message}`,
80
+ ...(op ? { entity: op.entityKey } : {}),
81
+ ...(op?.lexicon ? { lexicon: op.lexicon } : {}),
82
+ };
83
+ });
84
+ },
85
+ };
86
+ }
87
+
88
+ /** OPS017 without the workspace's identity rules. */
89
+ export const ops017: PostSynthCheck = ops017Check();
@@ -89,6 +89,26 @@ export interface ActivityContract<Args = unknown, Return = unknown> {
89
89
  * stack members internally) simply declares none.
90
90
  */
91
91
  entities?: string[];
92
+ /**
93
+ * What the activity returns that is secret (#3947): `true` for the whole
94
+ * return value, or the names of its top-level fields that are. A gate that
95
+ * resumes never stores a secret output in its checkpoint, so a gate whose
96
+ * later steps reference one doesn't resume, and OPS016 says so at build.
97
+ */
98
+ secret?: true | string[];
99
+ }
100
+
101
+ /**
102
+ * Whether a reference into `contract`'s return value at `path` reads a value
103
+ * the contract marks secret (#3947). A whole-value reference reads every
104
+ * field, so it is secret when any field is.
105
+ */
106
+ export function readsSecretOutput(contract: Pick<ActivityContract, "secret"> | undefined, path?: string): boolean {
107
+ const secret = contract?.secret;
108
+ if (secret === undefined) return false;
109
+ if (secret === true) return true;
110
+ if (!path) return secret.length > 0;
111
+ return secret.includes(path.split(".")[0]!);
92
112
  }
93
113
 
94
114
  /** Declare an activity contract. */
@@ -96,7 +116,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
96
116
  name: string,
97
117
  args: ArgsSchema,
98
118
  returns?: ReturnSchema,
99
- opts?: { entities?: string[] },
119
+ opts?: { entities?: string[]; secret?: true | string[] },
100
120
  ): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
101
121
  return {
102
122
  [CONTRACT_BRAND]: true,
@@ -104,6 +124,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
104
124
  args,
105
125
  ...(returns ? { returns } : {}),
106
126
  ...(opts?.entities && opts.entities.length > 0 ? { entities: opts.entities } : {}),
127
+ ...(opts?.secret === true || (Array.isArray(opts?.secret) && opts.secret.length > 0) ? { secret: opts!.secret } : {}),
107
128
  } as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
108
129
  }
109
130
 
@@ -5,6 +5,7 @@ import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receip
5
5
  import { receiptCheckInput } from "./receipt-store";
6
6
  import { gateApprovalProblems } from "./gate-approval";
7
7
  import { gatePointProblems } from "./gate-point";
8
+ import { gateAliasProblems } from "./gate-alias";
8
9
  import { makeOutProxy, stepOutput, type StepOutputRef, type WithStepRefs } from "./step-output-ref";
9
10
  import type { ChantBuildArgs } from "./activities/build";
10
11
  import type { ShellCmdArgs } from "./activities/shell";
@@ -135,10 +136,23 @@ export function activity(
135
136
  * The point's deciders and quorum decide, `chant workspace points answer`
136
137
  * answers an open question, and the run passes on the answer. See
137
138
  * {@link GateStep.point}.
139
+ *
140
+ * Pass `aliases` to keep the approvals recorded under the gate's old names
141
+ * after an Op is restructured (#3949): `gate("approve-tier-1", { plan:
142
+ * plan.out.planDigest, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] })`.
143
+ * A gate with aliases must bind a plan. See {@link GateStep.aliases}.
138
144
  */
139
145
  export function gate(
140
146
  name: string,
141
- opts?: { timeout?: string; description?: string; plan?: GateStep["plan"]; approval?: GateStep["approval"]; point?: GateStep["point"] },
147
+ opts?: {
148
+ timeout?: string;
149
+ description?: string;
150
+ plan?: GateStep["plan"];
151
+ approval?: GateStep["approval"];
152
+ point?: GateStep["point"];
153
+ resume?: boolean;
154
+ aliases?: GateStep["aliases"];
155
+ },
142
156
  ): GateStep {
143
157
  if (opts?.approval !== undefined) {
144
158
  const problems = gateApprovalProblems(opts.approval);
@@ -148,6 +162,10 @@ export function gate(
148
162
  const problems = gatePointProblems(opts.point, opts.approval !== undefined);
149
163
  if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
150
164
  }
165
+ if (opts?.aliases !== undefined) {
166
+ const problems = gateAliasProblems(opts.aliases, { plan: opts.plan, point: opts.point });
167
+ if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
168
+ }
151
169
  return {
152
170
  kind: "gate",
153
171
  gate: name,
@@ -156,6 +174,8 @@ export function gate(
156
174
  ...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
157
175
  ...(opts?.approval !== undefined ? { approval: opts.approval } : {}),
158
176
  ...(opts?.point !== undefined ? { point: opts.point } : {}),
177
+ ...(opts?.resume ? { resume: true } : {}),
178
+ ...(opts?.aliases !== undefined && opts.aliases.length > 0 ? { aliases: opts.aliases.map((a) => ({ ...(a.op !== undefined ? { op: a.op } : {}), gate: a.gate })) } : {}),
159
179
  };
160
180
  }
161
181
 
@@ -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
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * #3949 against a real `chant/lifecycle` branch: an approval recorded under
3
+ * a per-tier Op's gate passes the merged Op's gate that lists it as an
4
+ * alias. The run reads the old Op's ledger file, writes the carried line to
5
+ * its own, and the next run passes on that line alone.
6
+ */
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, commitAll, git, repo } from "../workspace/__fixtures__/contract-repo";
10
+ import { appendGateResolution, appendPendingGate, readGateLedger } from "../lifecycle/gate-ledger";
11
+ import { runOpLocally } from "./local-executor";
12
+ import { gitGateLedgerPort } from "./gate";
13
+ import { stepOutput } from "./step-output-ref";
14
+ import type { ActivityFn } from "./activity-registry";
15
+ import type { OpConfig } from "./types";
16
+
17
+ const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
18
+
19
+ let root: string;
20
+ beforeAll(() => {
21
+ root = repo({ "chant.config.json": "{}\n" }, false);
22
+ git(root, "config", "user.name", "Gate Test");
23
+ git(root, "config", "user.email", "gate@example.com");
24
+ commitAll(root, "c0");
25
+ });
26
+ afterAll(cleanScratch);
27
+
28
+ const applied: string[] = [];
29
+ const ACTIVITIES = new Map<string, ActivityFn>([
30
+ ["plan", async () => ({ planDigest: PLAN })],
31
+ ["apply", async (args) => (applied.push(String((args as { tier: string }).tier)), { ok: true })],
32
+ ]);
33
+
34
+ const merged: OpConfig = {
35
+ name: "upgrade",
36
+ overview: "upgrade every tier, one wave each",
37
+ phases: [
38
+ { name: "Plan", steps: [{ kind: "activity", id: "plan", fn: "plan" }] },
39
+ {
40
+ name: "Wave 1",
41
+ steps: [{ kind: "gate", gate: "approve-tier-1", plan: stepOutput("plan", "planDigest"), aliases: [{ op: "upgrade-tier-1", gate: "approve" }] }],
42
+ },
43
+ { name: "Apply 1", steps: [{ kind: "activity", fn: "apply", args: { tier: "1" } }] },
44
+ ],
45
+ };
46
+
47
+ describe("a gate alias on a real ledger", () => {
48
+ test("the merged Op passes on the old Op's approval, and keeps passing on the carried line", async () => {
49
+ const now = Date.now();
50
+ const iso = (s: number) => new Date(now + s * 1000).toISOString();
51
+ await appendPendingGate(
52
+ { op: "upgrade-tier-1", gate: "approve", timestamp: iso(-60), expiresAt: iso(3600), planDigest: PLAN, runId: "old-run" },
53
+ { cwd: root },
54
+ );
55
+ await appendGateResolution({ op: "upgrade-tier-1", gate: "approve", resolvedBy: "alice", timestamp: iso(-30), planDigest: PLAN }, { cwd: root });
56
+
57
+ const port = gitGateLedgerPort({ cwd: root });
58
+ const first = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-1" });
59
+ expect(first.status).toBe("ok");
60
+ expect(first.records.find((r) => r.fn === "gate:approve-tier-1")?.approval).toMatchObject({ resolvedBy: "alice", via: "alias" });
61
+
62
+ const ledger = await readGateLedger("upgrade", { cwd: root });
63
+ expect(ledger.resolutions).toHaveLength(1);
64
+ expect(ledger.resolutions[0]).toMatchObject({
65
+ op: "upgrade",
66
+ gate: "approve-tier-1",
67
+ resolvedBy: "alice",
68
+ planDigest: PLAN,
69
+ carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: iso(-30) },
70
+ });
71
+
72
+ const second = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-2" });
73
+ expect(second.status).toBe("ok");
74
+ expect(second.records.find((r) => r.fn === "gate:approve-tier-1")?.approval?.via).toBeUndefined();
75
+ expect((await readGateLedger("upgrade", { cwd: root })).resolutions).toHaveLength(1);
76
+ expect(applied).toEqual(["1", "1"]);
77
+ });
78
+ });