@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
package/src/op/gate.ts CHANGED
@@ -55,6 +55,8 @@ import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requ
55
55
  import { parseDuration } from "./duration";
56
56
  import { resolveGateRunLocator } from "./gate-run-locator";
57
57
  import { refuseGateWithoutRecording } from "../lifecycle/no-record";
58
+ import { gateCheckpointDigest, gateCheckpointText, readGateCheckpointText, writeGateCheckpoint, type GateCheckpoint } from "../lifecycle/gate-checkpoint";
59
+ import type { ResolvedGateAlias } from "./gate-alias";
58
60
 
59
61
  /**
60
62
  * What appending a pending fact learned about reaching the remote.
@@ -100,6 +102,15 @@ export interface GateLedgerPort {
100
102
  readApplied?(op: string): Promise<AppliedGateRecord[]>;
101
103
  /** Record that a wave is about to apply under an approval (#3784), which spends it. */
102
104
  appendApplied?(record: AppliedGateRecord): Promise<{ pushed: boolean; pushWarning?: string }>;
105
+ /**
106
+ * Store the checkpoint of a run that stops at a gate that resumes (#3947),
107
+ * at `_gates/<op>/checkpoints/<runId>.json`. Written before the pending
108
+ * fact that names its digest, and pushed with it. Absent on a port that
109
+ * keeps none, and the gate then doesn't resume.
110
+ */
111
+ writeCheckpoint?(checkpoint: GateCheckpoint): Promise<void>;
112
+ /** The text of a run's checkpoint, or null when there is none (#3947). */
113
+ readCheckpoint?(op: string, runId: string): Promise<string | null>;
103
114
  }
104
115
 
105
116
  /** A workspace's rule for which approvals of a gate count (#3163): a signed approval, from a class when it names one. */
@@ -148,6 +159,13 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
148
159
  const { record } = await appendPendingGate(input, opts);
149
160
  return { record, ...(await pushLifecycleReport(opts)) };
150
161
  },
162
+ async writeCheckpoint(checkpoint) {
163
+ await writeGateCheckpoint(checkpoint, opts);
164
+ },
165
+ async readCheckpoint(op, runId) {
166
+ await requireLifecycleLedger(opts);
167
+ return readGateCheckpointText(op, runId, opts);
168
+ },
151
169
  async readApplied(op) {
152
170
  await requireLifecycleLedger(opts);
153
171
  const { dir, file } = appliedGatePath(op);
@@ -181,16 +199,30 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
181
199
  /** A gate ledger held in memory — what a test, or a caller that has already read the ledger, hands {@link evaluateGate}. */
182
200
  export function memoryGateLedgerPort(
183
201
  seed: { resolutions?: GateResolutionRecord[]; pending?: PendingGateRecord[]; applied?: AppliedGateRecord[] } = {},
184
- ): GateLedgerPort & { appended: PendingGateRecord[]; resolved: GateResolutionRecord[]; applied: AppliedGateRecord[] } {
202
+ ): GateLedgerPort & {
203
+ appended: PendingGateRecord[];
204
+ resolved: GateResolutionRecord[];
205
+ applied: AppliedGateRecord[];
206
+ /** Checkpoint texts by `<op>/<runId>` (#3947). */
207
+ checkpoints: Map<string, string>;
208
+ } {
185
209
  const resolutions = [...(seed.resolutions ?? [])];
186
210
  const pending = [...(seed.pending ?? [])];
187
211
  const appended: PendingGateRecord[] = [];
188
212
  const resolved: GateResolutionRecord[] = [];
189
213
  const applied: AppliedGateRecord[] = [...(seed.applied ?? [])];
214
+ const checkpoints = new Map<string, string>();
190
215
  return {
191
216
  appended,
192
217
  resolved,
193
218
  applied,
219
+ checkpoints,
220
+ async writeCheckpoint(checkpoint) {
221
+ checkpoints.set(`${checkpoint.op}/${checkpoint.runId}`, gateCheckpointText(checkpoint));
222
+ },
223
+ async readCheckpoint(op, runId) {
224
+ return checkpoints.get(`${op}/${runId}`) ?? null;
225
+ },
194
226
  async readApplied(op) {
195
227
  return applied.filter((a) => a.op === op);
196
228
  },
@@ -271,6 +303,24 @@ export interface GateCheckInput {
271
303
  fields?: Record<string, unknown>;
272
304
  /** The pending fact it records carries `neverOverMcp` (#3821, chant#3485). */
273
305
  neverOverMcp?: boolean;
306
+ /**
307
+ * The checkpoint of a run at a gate that resumes (#3947). Given, a pending
308
+ * fact this records first stores it through the port's `writeCheckpoint`
309
+ * and carries its digest as `checkpointDigest`. A standing fact with no
310
+ * checkpoint is recorded again, so the gate gets one.
311
+ */
312
+ checkpoint?: GateCheckpoint;
313
+ /**
314
+ * The gate's old names (#3949), each with its Op filled in
315
+ * (`resolveGateAliases` in `./gate-alias.ts`). Read only on a gate bound
316
+ * to a plan. A person's approval recorded under one counts toward this
317
+ * gate when it answers that name's standing pending fact, the fact hasn't
318
+ * expired, it is for `planDigest` and `environment`, neither name has
319
+ * spent it, and it passes this gate's approval rule. An approval this
320
+ * counts is written again under this gate (`carriedFrom`), and a line
321
+ * carried earlier counts only while its alias is still declared.
322
+ */
323
+ aliases?: readonly ResolvedGateAlias[];
274
324
  }
275
325
 
276
326
  /**
@@ -335,11 +385,14 @@ export type GateCheck =
335
385
  /**
336
386
  * Set on a gate that declares `approval` (#2508). `"quorum"` when enough
337
387
  * human approvals were recorded, `"policy"` when an `enforce`-mode permit
338
- * passed it on its own.
388
+ * passed it on its own. `"alias"` (#3949) when an approval recorded
389
+ * under one of the gate's aliases counted, on any gate.
339
390
  */
340
- via?: "quorum" | "policy";
391
+ via?: "quorum" | "policy" | "alias";
341
392
  /** Every approval that counted toward the quorum. Set with `via`. */
342
393
  approvals?: GateResolutionRecord[];
394
+ /** The approvals this run carried from an alias and wrote under the gate's name (#3949). Set when `via` is `"alias"`. */
395
+ carried?: GateResolutionRecord[];
343
396
  }
344
397
  | {
345
398
  satisfied: false;
@@ -500,6 +553,103 @@ export function humanQuorumSince(pending: readonly PendingGateRecord[], standing
500
553
  return anchor.timestamp;
501
554
  }
502
555
 
556
+ /**
557
+ * The approvals that answer `standing` (#3949): of its gate, environment and
558
+ * plan, recorded since it, or on a gate with an `approval` block since the
559
+ * human quorum's anchor ({@link humanQuorumSince}). The rule
560
+ * `resolutionAnswering` (`./gate-resume.ts`) decides by, without the tally:
561
+ * each one counts on its own toward a gate an alias names.
562
+ */
563
+ export function approvalsAnswering(
564
+ resolutions: readonly GateResolutionRecord[],
565
+ standing: PendingGateRecord,
566
+ pending: readonly PendingGateRecord[] = [standing],
567
+ ): GateResolutionRecord[] {
568
+ const since = new Date(standing.approval ? humanQuorumSince(pending, standing) : standing.timestamp).getTime();
569
+ return resolutions.filter(
570
+ (r) =>
571
+ r.gate === standing.gate && r.environment === standing.environment && samePlanDigest(r.planDigest, standing.planDigest) &&
572
+ new Date(r.timestamp).getTime() >= since,
573
+ );
574
+ }
575
+
576
+ /**
577
+ * The line a run writes for an approval it carries from an alias (#3949):
578
+ * the approval under `op`/`gate` at `now`, citing the line it came from.
579
+ * The approver, plan, environment, relay and seal are the original's. A
580
+ * policy decision is left behind, since it was evaluated against another
581
+ * gate, and so is a checkpoint digest, which moves onto `carriedFrom`: the
582
+ * approver saw that checkpoint, not one this gate stores, so a carried
583
+ * approval never lets a run resume.
584
+ */
585
+ export function carriedApproval(r: GateResolutionRecord, op: string, gate: string, now: string): GateResolutionInput {
586
+ return {
587
+ op,
588
+ gate,
589
+ resolvedBy: r.resolvedBy,
590
+ ...(r.relayedBy !== undefined ? { relayedBy: r.relayedBy } : {}),
591
+ timestamp: now,
592
+ ...(r.note ? { note: r.note } : {}),
593
+ ...(r.url ? { url: r.url } : {}),
594
+ ...(r.planDigest !== undefined ? { planDigest: r.planDigest } : {}),
595
+ ...(r.environment !== undefined ? { environment: r.environment } : {}),
596
+ ...(r.origin ? { origin: r.origin } : {}),
597
+ ...(r.sameOriginOverride ? { sameOriginOverride: true } : {}),
598
+ approver: approverOf(r),
599
+ ...(r.seal ? { seal: r.seal } : {}),
600
+ carriedFrom: {
601
+ op: r.op,
602
+ gate: r.gate,
603
+ timestamp: r.timestamp,
604
+ ...(r.checkpointDigest !== undefined ? { checkpointDigest: r.checkpointDigest } : {}),
605
+ },
606
+ };
607
+ }
608
+
609
+ /**
610
+ * The approvals recorded under `input.aliases` that may count toward this
611
+ * gate (#3949), oldest first. Each answers its alias's standing pending
612
+ * fact, which hasn't expired and was recorded for this plan and
613
+ * environment. Each is a person's, is spent under neither name, hasn't been
614
+ * carried already (`ledger` is this gate's own, so a carried line that was
615
+ * spent keeps its original from being carried again), and passes `rule`,
616
+ * whose seal check reads the names the approval was signed under. One the
617
+ * rule refuses is added to `refused`.
618
+ */
619
+ async function aliasApprovals(
620
+ port: GateLedgerPort,
621
+ input: GateCheckInput & { aliases: readonly ResolvedGateAlias[] },
622
+ ledger: readonly GateResolutionRecord[],
623
+ rule: GateApprovalRule | null,
624
+ now: string,
625
+ refused: GateRefusedApproval[],
626
+ ): Promise<GateResolutionRecord[]> {
627
+ const at = (iso: string) => new Date(iso).getTime();
628
+ const carried = ledger.filter((r) => r.gate === input.gate && r.carriedFrom !== undefined);
629
+ const wasCarried = (r: GateResolutionRecord) =>
630
+ carried.some((c) => c.carriedFrom!.op === r.op && c.carriedFrom!.gate === r.gate && at(c.carriedFrom!.timestamp) === at(r.timestamp) && c.resolvedBy === r.resolvedBy);
631
+ const ledgers = new Map<string, Awaited<ReturnType<GateLedgerPort["read"]>>>();
632
+ const out: GateResolutionRecord[] = [];
633
+ for (const alias of input.aliases) {
634
+ let other = ledgers.get(alias.op);
635
+ if (!other) ledgers.set(alias.op, (other = await port.read(alias.op)));
636
+ const pending = other.pending.filter((p) => p.environment === input.environment);
637
+ const standing = latestPendingGate(pending, alias.gate);
638
+ if (!standing || isPendingGateExpired(standing, now) || !samePlanDigest(standing.planDigest, input.planDigest)) continue;
639
+ const spent = alias.op === input.op && input.spent ? input.spent : ((await port.readApplied?.(alias.op)) ?? []);
640
+ for (const r of approvalsAnswering(other.resolutions, standing, pending)) {
641
+ if (r.carriedFrom !== undefined || approverOf(r).kind !== "human" || isApprovalSpent(spent, r) || wasCarried(r)) continue;
642
+ const reason = rule?.refuses(r) ?? null;
643
+ if (reason !== null) {
644
+ refused.push({ resolution: r, reason: `carried from ${alias.op}/${alias.gate}: ${reason}` });
645
+ continue;
646
+ }
647
+ out.push(r);
648
+ }
649
+ }
650
+ return out.sort((a, b) => at(a.timestamp) - at(b.timestamp));
651
+ }
652
+
503
653
  /** Whether two resolved approval blocks are the same, so a standing pending fact still describes this gate. */
504
654
  function sameApproval(a: ResolvedGateApproval | undefined, b: ResolvedGateApproval | undefined): boolean {
505
655
  return JSON.stringify(a ?? null, sortedJsonReplacer) === JSON.stringify(b ?? null, sortedJsonReplacer);
@@ -546,6 +696,9 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
546
696
  const rule = (await port.approvalRule?.(input.gate)) ?? null;
547
697
  const standing = latestPendingGate(pending, input.gate);
548
698
  const since = standing?.timestamp ?? EPOCH;
699
+ // #3949: aliases admit only approvals of this run's plan, and carrying one writes a line.
700
+ const aliases = input.planDigest !== undefined && port.appendResolution ? (input.aliases ?? []) : [];
701
+ const aliased = (from: { op: string; gate: string }) => aliases.some((a) => a.op === from.op && a.gate === from.gate);
549
702
  // #3841: an approval of this plan the rule refuses is named, so a wait says why.
550
703
  const refused: GateRefusedApproval[] = [];
551
704
  const resolutions = (
@@ -553,6 +706,8 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
553
706
  ? ledger.resolutions
554
707
  : ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env)
555
708
  )
709
+ // #3949: a carried approval counts only while the gate still names its alias.
710
+ .filter((r) => r.gate !== input.gate || r.carriedFrom === undefined || aliased(r.carriedFrom))
556
711
  .filter((r) => input.spent === undefined || r.gate !== input.gate || !isApprovalSpent(input.spent, r))
557
712
  .filter((r) => {
558
713
  if (rule === null || r.gate !== input.gate) return true;
@@ -561,8 +716,16 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
561
716
  if (samePlanDigest(r.planDigest, input.planDigest) && new Date(r.timestamp).getTime() >= new Date(since).getTime()) refused.push({ resolution: r, reason });
562
717
  return false;
563
718
  });
719
+ const fromAliases = aliases.length > 0 ? await aliasApprovals(port, { ...input, aliases }, ledger.resolutions, rule, now, refused) : [];
564
720
  refused.sort((a, b) => new Date(a.resolution.timestamp).getTime() - new Date(b.resolution.timestamp).getTime());
565
721
 
722
+ // #3949: write the approvals that counted from an alias under this gate.
723
+ const carry = async (records: readonly GateResolutionRecord[]): Promise<GateResolutionRecord[]> => {
724
+ const written: GateResolutionRecord[] = [];
725
+ for (const r of records) written.push((await port.appendResolution!(carriedApproval(r, input.op, input.gate, now))).record);
726
+ return written;
727
+ };
728
+
566
729
  let mismatched: GateResolutionRecord | undefined;
567
730
  let quorum: GateQuorumProgress | undefined;
568
731
  if (input.approval) {
@@ -587,9 +750,28 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
587
750
  }
588
751
  mismatched = tally.mismatched;
589
752
  quorum = { approvers: tally.counted.map((r) => r.resolvedBy), need: tally.need };
753
+ // #3949: old and new approvals add up toward one quorum, each approver once.
754
+ const counted = new Set(tally.counted.map((r) => r.resolvedBy));
755
+ const extra = fromAliases.filter((r) => !counted.has(r.resolvedBy));
756
+ if (extra.length > 0) {
757
+ const asCarried = new Map(extra.map((r) => [{ version: 1, ...carriedApproval(r, input.op, input.gate, now) } as GateResolutionRecord, r]));
758
+ const combined = tallyGateApprovals([...resolutions, ...asCarried.keys()], input.gate, since, input.planDigest, input.approval, humansSince);
759
+ if (combined.counted.length >= combined.need) {
760
+ const written = await carry(combined.counted.filter((r) => asCarried.has(r)).map((r) => asCarried.get(r)!));
761
+ const approvals = [...combined.counted.filter((r) => !asCarried.has(r)), ...written];
762
+ return { satisfied: true, resolution: written[written.length - 1]!, via: "alias", approvals, carried: written };
763
+ }
764
+ quorum = { approvers: combined.counted.map((r) => r.resolvedBy), need: combined.need };
765
+ }
590
766
  } else {
591
767
  const found = latestResolutionForPlan(resolutions, input.gate, since, input.planDigest);
592
768
  if (found.resolution) return { satisfied: true, resolution: found.resolution };
769
+ // #3949: else the newest approval an alias admits passes it, written under this gate.
770
+ const newest = fromAliases[fromAliases.length - 1];
771
+ if (newest) {
772
+ const written = await carry([newest]);
773
+ return { satisfied: true, resolution: written[0]!, via: "alias", carried: written };
774
+ }
593
775
  mismatched = found.mismatched;
594
776
  }
595
777
 
@@ -614,13 +796,24 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
614
796
  // The same holds for the approval block (#2508): a pending fact recorded
615
797
  // under another policy version or another context would have `chant
616
798
  // approve` evaluate the policy against something this run no longer has.
799
+ // #3947: and for the checkpoint, so a gate that resumes gets one. A fact
800
+ // that already carries a checkpoint stands: an approval copies that one.
617
801
  if (
618
802
  standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) &&
619
- sameApproval(standing.approval, input.approval)
803
+ sameApproval(standing.approval, input.approval) &&
804
+ (input.checkpoint === undefined || port.writeCheckpoint === undefined || standing.checkpointDigest !== undefined)
620
805
  ) {
621
806
  return { satisfied: false, pending: standing, recorded: false, ...asMismatch };
622
807
  }
623
808
 
809
+ // The checkpoint is written before the fact that names it, so a fact never
810
+ // points at a file that isn't there.
811
+ let checkpointDigest: string | undefined;
812
+ if (input.checkpoint !== undefined && port.writeCheckpoint) {
813
+ checkpointDigest = gateCheckpointDigest(input.checkpoint);
814
+ await port.writeCheckpoint(input.checkpoint);
815
+ }
816
+
624
817
  const url = resolveApprovalUrl();
625
818
  // #3683: in CI, where this job runs, so an approval can start it again.
626
819
  const resume = resolveGateRunLocator();
@@ -637,6 +830,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
637
830
  ...(url ? { url } : {}),
638
831
  ...(resume ? { resume } : {}),
639
832
  ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
833
+ ...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
640
834
  ...(env !== undefined ? { environment: env } : {}),
641
835
  ...(input.approval ? { approval: input.approval } : {}),
642
836
  ...(input.neverOverMcp ? { neverOverMcp: true as const } : {}),
package/src/op/index.ts CHANGED
@@ -156,9 +156,12 @@ export type {
156
156
  export { renderHuman, renderJson } from "./local-output";
157
157
  export {
158
158
  activityContract, isActivityContract, collectActivityContracts, validateActivitySteps, KNOWN_ACTIVITY_PROFILES,
159
- pathExistsInSchema,
159
+ pathExistsInSchema, readsSecretOutput,
160
160
  } from "./activity-contract";
161
161
  export type { ActivityContract, ActivityContractIssue } from "./activity-contract";
162
+ export { validateGateResume, resumeGatesOf, decideGateResume } from "./gate-checkpoint";
163
+ export type { ResumeGate, GateResumeDecision } from "./gate-checkpoint";
164
+ export type { GateCheckpoint } from "../lifecycle/gate-checkpoint";
162
165
  export { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope, makeOutProxy } from "./step-output-ref";
163
166
  export type { StepOutputRef, WithStepRefs } from "./step-output-ref";
164
167
  export type { NamedActivityStep } from "./builders";
@@ -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[] = [];