@intentius/chant 0.124.25 → 0.124.27

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 (113) 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/gate-decision.d.ts +1 -1
  8. package/dist/gate-decision.d.ts.map +1 -1
  9. package/dist/gate-decision.js +1 -1
  10. package/dist/lifecycle/gate-audit.d.ts +7 -1
  11. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  12. package/dist/lifecycle/gate-audit.js +40 -3
  13. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  14. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  15. package/dist/lifecycle/gate-checkpoint.js +103 -0
  16. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/gate-ledger.js +18 -0
  19. package/dist/lifecycle/run-ledger.d.ts +2 -0
  20. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  21. package/dist/lifecycle/run-ledger.js +2 -0
  22. package/dist/lint/rules/op/index.d.ts +6 -2
  23. package/dist/lint/rules/op/index.d.ts.map +1 -1
  24. package/dist/lint/rules/op/index.js +7 -3
  25. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  26. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  27. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  28. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  29. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  30. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  31. package/dist/op/activity-contract.d.ts +14 -0
  32. package/dist/op/activity-contract.d.ts.map +1 -1
  33. package/dist/op/activity-contract.js +16 -0
  34. package/dist/op/activity-registry.d.ts +6 -3
  35. package/dist/op/activity-registry.d.ts.map +1 -1
  36. package/dist/op/activity-registry.js +7 -5
  37. package/dist/op/builders.d.ts +7 -0
  38. package/dist/op/builders.d.ts.map +1 -1
  39. package/dist/op/builders.js +13 -0
  40. package/dist/op/discover.d.ts.map +1 -1
  41. package/dist/op/discover.js +14 -7
  42. package/dist/op/gate-alias.d.ts +57 -0
  43. package/dist/op/gate-alias.d.ts.map +1 -0
  44. package/dist/op/gate-alias.js +127 -0
  45. package/dist/op/gate-checkpoint.d.ts +123 -0
  46. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  47. package/dist/op/gate-checkpoint.js +280 -0
  48. package/dist/op/gate.d.ts +54 -2
  49. package/dist/op/gate.d.ts.map +1 -1
  50. package/dist/op/gate.js +142 -1
  51. package/dist/op/index.d.ts +4 -1
  52. package/dist/op/index.d.ts.map +1 -1
  53. package/dist/op/index.js +2 -1
  54. package/dist/op/local-executor.d.ts +57 -2
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/local-executor.js +136 -6
  57. package/dist/op/local-output.d.ts.map +1 -1
  58. package/dist/op/local-output.js +20 -2
  59. package/dist/op/op-ir.d.ts +4 -0
  60. package/dist/op/op-ir.d.ts.map +1 -1
  61. package/dist/op/op-ir.js +4 -0
  62. package/dist/op/runtime.d.ts +15 -0
  63. package/dist/op/runtime.d.ts.map +1 -1
  64. package/dist/op/runtimes/local.d.ts.map +1 -1
  65. package/dist/op/runtimes/local.js +9 -2
  66. package/dist/op/types.d.ts +31 -0
  67. package/dist/op/types.d.ts.map +1 -1
  68. package/dist/wave-runtime.mjs +225 -245
  69. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  70. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  71. package/dist/workspace/trust/gate-seal.js +41 -2
  72. package/dist/workspace/trust/seal.d.ts +1 -1
  73. package/dist/workspace/trust/seal.d.ts.map +1 -1
  74. package/dist/workspace/trust/seal.js +1 -1
  75. package/package.json +1 -1
  76. package/src/cli/commands/build.ts +9 -2
  77. package/src/cli/commands/lint.ts +4 -2
  78. package/src/cli/compiled-cli.e2e.test.ts +31 -6
  79. package/src/cli/handlers/approve.ts +46 -4
  80. package/src/cli/handlers/operator.test.ts +91 -0
  81. package/src/gate-decision.ts +2 -0
  82. package/src/lifecycle/gate-audit.ts +41 -4
  83. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  84. package/src/lifecycle/gate-checkpoint.ts +133 -0
  85. package/src/lifecycle/gate-ledger.ts +61 -0
  86. package/src/lifecycle/run-ledger.ts +4 -0
  87. package/src/lint/rules/op/index.ts +9 -3
  88. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  89. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  90. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  91. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  92. package/src/op/activity-contract.ts +22 -1
  93. package/src/op/activity-registry.ts +7 -5
  94. package/src/op/builders.ts +21 -1
  95. package/src/op/discover-root.test.ts +39 -0
  96. package/src/op/discover.ts +15 -8
  97. package/src/op/gate-alias.e2e.test.ts +78 -0
  98. package/src/op/gate-alias.test.ts +350 -0
  99. package/src/op/gate-alias.ts +137 -0
  100. package/src/op/gate-checkpoint.test.ts +284 -0
  101. package/src/op/gate-checkpoint.ts +322 -0
  102. package/src/op/gate.ts +198 -4
  103. package/src/op/index.ts +4 -1
  104. package/src/op/local-executor.ts +180 -7
  105. package/src/op/local-output.ts +18 -2
  106. package/src/op/op-ir.test.ts +25 -0
  107. package/src/op/op-ir.ts +8 -0
  108. package/src/op/runtime.ts +8 -0
  109. package/src/op/runtimes/local.ts +9 -2
  110. package/src/op/types.ts +32 -0
  111. package/src/workspace/trust/gate-seal.test.ts +29 -2
  112. package/src/workspace/trust/gate-seal.ts +46 -2
  113. package/src/workspace/trust/seal.ts +1 -1
@@ -28,6 +28,10 @@ function formatDuration(ms: number): string {
28
28
  * stays clean for piping (the `--json` renderer owns stdout).
29
29
  */
30
30
  export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed = false): void {
31
+ // #3947: a run that resumed at a gate says where it picked up.
32
+ if (result.resumed) {
33
+ write(`[resume] at gate "${result.resumed.gate}", from run ${result.resumed.run}'s checkpoint ${result.resumed.checkpoint}`);
34
+ }
31
35
  let currentPhase: string | undefined;
32
36
  for (const record of result.records) {
33
37
  if (record.phase !== currentPhase) {
@@ -36,7 +40,9 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
36
40
  }
37
41
  const mark = record.status === "ok" ? "✓" : record.status === "fail" ? "✗" : "•";
38
42
  const call = `${record.fn}(${formatArgs(record.args ?? {})})`;
39
- if (record.status === "skipped") {
43
+ if (record.status === "skipped" && record.resumed) {
44
+ write(` ${mark} ${call} from run ${record.resumed.run}`);
45
+ } else if (record.status === "skipped") {
40
46
  write(` ${mark} ${call} skipped`);
41
47
  } else {
42
48
  write(` ${mark} ${call} ${formatDuration(record.durationMs)}`);
@@ -47,6 +53,10 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
47
53
  if (record.approval) {
48
54
  write(` [approved] ${record.approval.resolvedBy} at ${record.approval.timestamp}` +
49
55
  (record.approval.url ? ` (${record.approval.url})` : ""));
56
+ // #3949: an approval carried from one of the gate's aliases names where it came from.
57
+ for (const c of record.approval.carriedFrom ?? []) {
58
+ write(` [carried] ${c.resolvedBy}'s approval of ${c.op}/${c.gate} at ${c.timestamp}`);
59
+ }
50
60
  }
51
61
  if (record.refusal) {
52
62
  write(` [refused] ${record.refusal}`);
@@ -72,6 +82,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
72
82
  write(`[work] nothing claimed: ${work.refusal}`);
73
83
  }
74
84
 
85
+ for (const note of result.resumeNotes ?? []) write(`[resume] ${note}`);
86
+
75
87
  const total = `${(result.totalMs / 1000).toFixed(1)}s`;
76
88
  if (result.status === "ok") {
77
89
  write(`Op "${result.op}" completed in ${total}`);
@@ -104,6 +116,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
104
116
  write(` approve : ${approveCommand(gate.op, gate.gate, undefined, gate.planDigest, sealed)}`);
105
117
  if (gate.url) write(` approve at: ${gate.url}`);
106
118
  write(` expires : ${gate.expiresAt}`);
119
+ // #3947: the approval also lets the next run start here.
120
+ if (gate.checkpointDigest) write(` resume : once approved, the next run starts at this gate (checkpoint ${gate.checkpointDigest})`);
107
121
  // #2310: this run's own append reached only the local chant/lifecycle
108
122
  // branch, not the remote. The pending fact is still correct — the gate is
109
123
  // still right to stand — but an operator working from a clone of the
@@ -139,7 +153,9 @@ export function renderJson(result: OpRunResult, write: Writer = stdout, sealed =
139
153
  const push = result.gatePushed === false
140
154
  ? { pushed: false, pushWarning: result.gatePushWarning ?? "the pending fact was recorded locally only" }
141
155
  : {};
142
- write(JSON.stringify({ ...result.record, ...approve, ...push }));
156
+ // #3947: why a gate that resumes did not, beside the record like `push`.
157
+ const notes = result.resumeNotes ? { resumeNotes: result.resumeNotes } : {};
158
+ write(JSON.stringify({ ...result.record, ...approve, ...push, ...notes }));
143
159
  }
144
160
 
145
161
  export type { OpRunResult, StepRecord };
@@ -257,6 +257,31 @@ describe("op.json IR", () => {
257
257
  expect(serializeOpIR(reconstructed)).toBe(text);
258
258
  });
259
259
 
260
+ // #3947: a gate that resumes says so in op.json, for a runtime that reads it.
261
+ it("carries a gate's resume through op.json and back", () => {
262
+ const config: OpConfig = {
263
+ name: "live-apply",
264
+ overview: "o",
265
+ phases: [phase("Gate", [{ kind: "gate", gate: "approve-live-apply", resume: true }])],
266
+ };
267
+ const text = serializeOpIR(config);
268
+ expect((JSON.parse(text) as OpIR).phases[0].steps[0]).toMatchObject({ kind: "gate", resume: true });
269
+ expect(opConfigFromIR(JSON.parse(text) as OpIR).phases[0]!.steps[0]).toMatchObject({ kind: "gate", resume: true });
270
+ });
271
+
272
+ // #3949: a gate's aliases travel in op.json, so a runtime that reads it counts the same approvals.
273
+ it("carries a gate's aliases through op.json and back", () => {
274
+ const aliases = [{ op: "upgrade-tier-1", gate: "approve" }, { gate: "approve-old" }];
275
+ const config: OpConfig = {
276
+ name: "upgrade",
277
+ overview: "o",
278
+ phases: [phase("Gate", [{ kind: "gate", gate: "approve-tier-1", plan: "jcs1-sha256:" + "a".repeat(64), aliases }])],
279
+ };
280
+ const text = serializeOpIR(config);
281
+ expect((JSON.parse(text) as OpIR).phases[0].steps[0]).toMatchObject({ kind: "gate", aliases });
282
+ expect(opConfigFromIR(JSON.parse(text) as OpIR).phases[0]!.steps[0]).toMatchObject({ kind: "gate", aliases });
283
+ });
284
+
260
285
  it("round-trips a minimal Op (no gate, effect, onFailure or labels) too", () => {
261
286
  const original: OpConfig = { name: "minimal", overview: "o", phases: [phase("Only", [shell("echo hi")])] };
262
287
  const text = serializeOpIR(original);
package/src/op/op-ir.ts CHANGED
@@ -151,6 +151,10 @@ export interface OpIRGateStep {
151
151
  approval?: GateStep["approval"];
152
152
  /** The decision point the gate asks (#3170), carried as authored. Absent on a gate that `chant approve` passes. */
153
153
  point?: GateStep["point"];
154
+ /** The gate resumes once approved (#3947). Absent on a gate that starts its run again at the first phase. */
155
+ resume?: true;
156
+ /** The gate's old names (#3949), carried as authored. Absent on a gate that has none. */
157
+ aliases?: GateStep["aliases"];
154
158
  }
155
159
 
156
160
  export interface OpIREffectStep {
@@ -263,6 +267,8 @@ function irGateStep(step: GateStep): OpIRGateStep {
263
267
  ...(step.plan !== undefined ? { plan: step.plan } : {}),
264
268
  ...(step.approval !== undefined ? { approval: step.approval } : {}),
265
269
  ...(step.point !== undefined ? { point: step.point } : {}),
270
+ ...(step.resume ? { resume: true as const } : {}),
271
+ ...(step.aliases !== undefined && step.aliases.length > 0 ? { aliases: step.aliases } : {}),
266
272
  };
267
273
  }
268
274
 
@@ -419,6 +425,8 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
419
425
  ...(step.plan !== undefined ? { plan: step.plan } : {}),
420
426
  ...(step.approval !== undefined ? { approval: step.approval } : {}),
421
427
  ...(step.point !== undefined ? { point: step.point } : {}),
428
+ ...(step.resume ? { resume: true } : {}),
429
+ ...(step.aliases !== undefined && step.aliases.length > 0 ? { aliases: step.aliases } : {}),
422
430
  };
423
431
  }
424
432
  return {
package/src/op/runtime.ts CHANGED
@@ -59,6 +59,8 @@ export interface OpRunStepRecord {
59
59
  point?: WaitingPoint;
60
60
  /** The gate this step's command stopped at (#2779), for a step skipped because of it. */
61
61
  gate?: { op: string; gate: string };
62
+ /** The run whose checkpoint supplied this step's output, for a step a resumed run skipped (#3947). */
63
+ resumed?: { run: string; gate: string };
62
64
  }
63
65
 
64
66
  /** One phase's steps and the verdict they add up to. */
@@ -151,6 +153,12 @@ export interface OpRunRecord {
151
153
  point?: WaitingPoint & { since: string };
152
154
  /** The steward whose turn the run was, when a steward ran it (#2749). */
153
155
  steward?: string;
156
+ /**
157
+ * The gate this run resumed at (#3947), the run whose checkpoint it carried
158
+ * on from, and the checkpoint's digest. Absent on a run that started at the
159
+ * first phase.
160
+ */
161
+ resumed?: { gate: string; run: string; checkpoint: string };
154
162
  }
155
163
 
156
164
  /** An {@link OpRunRecord} before the ledger stamps its version and mints an id. */
@@ -22,8 +22,9 @@ import { lexiconNames } from "../../lexicon-module";
22
22
  import { loadChantConfig } from "../../config";
23
23
  import { loadActivities, loadProfiles } from "../activity-registry";
24
24
  import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
25
+ import { loadActivityContracts } from "../activity-contract-registry";
26
+ import { resumeGatesOf } from "../gate-checkpoint";
25
27
  import { stepHeartbeatMs } from "../step-console";
26
- import { runComponents } from "../../components/cli-support";
27
28
  import { discoverOps } from "../discover";
28
29
  import { readRunLedger, runEnvOf, DEFAULT_RUN_ENV } from "../../lifecycle/run-ledger";
29
30
  import type { OpConfig } from "../types";
@@ -139,6 +140,8 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
139
140
  }
140
141
 
141
142
  const [activities, profiles] = await Promise.all([loadActivities(lexicons), loadProfiles()]);
143
+ // #3947: a gate that resumes stores no output a contract marks secret.
144
+ const contracts = resumeGatesOf(op).length > 0 ? await loadActivityContracts(lexicons) : undefined;
142
145
 
143
146
  // A caller that names the run (#3539: `chant acp` on fountain passes the
144
147
  // conversation id) gets its id on the record; otherwise one is minted.
@@ -179,6 +182,7 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
179
182
  stepOutput: (line) => process.stderr.write(`${line}\n`),
180
183
  stepHeartbeatMs: stepHeartbeatMs(process.env, (message) => process.stderr.write(`warning: ${message}\n`)),
181
184
  ...(startOpts.work ? { work: startOpts.work } : {}),
185
+ ...(contracts ? { contracts } : {}),
182
186
  onWorkLeaseWarning: (message) =>
183
187
  process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
184
188
  },
@@ -232,7 +236,10 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
232
236
  );
233
237
  },
234
238
 
235
- runComponents(path, selector, options) {
239
+ async runComponents(path, selector, options) {
240
+ // Imported here, not at the top: the components driver is most of core's components tree, and this module is
241
+ // re-exported by `@intentius/chant/op`, which every `*.op.ts` imports (#3982).
242
+ const { runComponents } = await import("../../components/cli-support");
236
243
  return runComponents(path, selector, options);
237
244
  },
238
245
  };
package/src/op/types.ts CHANGED
@@ -298,6 +298,38 @@ export interface GateStepBase {
298
298
  * `./gate-point.ts`.
299
299
  */
300
300
  point?: string | GatePoint;
301
+ /**
302
+ * A run that stops at this gate can be resumed here once it is approved
303
+ * (#3947), rather than starting again at the first phase. The run stores
304
+ * the step outputs a later step references in a checkpoint on
305
+ * `chant/lifecycle`, and the next run after `chant approve` re-runs the
306
+ * step that produced the gate's `plan`, checks the checkpoint, and carries
307
+ * on from the gate. Any check that fails starts the run at the first phase,
308
+ * and the run says why. See `./gate-checkpoint.ts`.
309
+ *
310
+ * Only a gate at the top of a main phase resumes, and not one that asks a
311
+ * decision point or sits in an Op with a `workLease`. OPS016 refuses the
312
+ * rest at build.
313
+ */
314
+ resume?: boolean;
315
+ /**
316
+ * The names this gate had before an Op was restructured (#3949), so the
317
+ * approvals already recorded under them still count. `op` defaults to this
318
+ * Op. An approval recorded under an alias counts toward this gate when it
319
+ * answers that name's standing pending fact, the fact hasn't expired, it
320
+ * is for the plan this run planned and the same environment, it hasn't
321
+ * been spent, it passes this gate's `identity.gates` rule, and a person
322
+ * made it. The run then writes it again under this gate's name, citing
323
+ * the line it came from (`carriedFrom`). Only a gate bound to a `plan`
324
+ * takes aliases, and OPS017 refuses an alias that is still a gate.
325
+ */
326
+ aliases?: GateAlias[];
327
+ }
328
+
329
+ /** A name a gate had before (#3949): a gate of `op`, or of the gate's own Op when `op` is absent. */
330
+ export interface GateAlias {
331
+ op?: string;
332
+ gate: string;
301
333
  }
302
334
 
303
335
  /**
@@ -11,7 +11,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { afterAll, describe, expect, test } from "vitest";
14
- import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
14
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
15
15
  import { checkGateSeal } from "./seal";
16
16
  import { emptyPolicy, parseAllowedSigners, type TrustPolicy } from "./policy";
17
17
  import { hasSshKeygen } from "./test-repo";
@@ -96,7 +96,7 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
96
96
  const policy = policyOf(`alice ${k.pub}\n`);
97
97
  const seal = { signer: "alice", key: "SHA256:x", signature };
98
98
  expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
99
- for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }]) {
99
+ for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }, { checkpointDigest: "jcs1-sha256:added" }]) {
100
100
  const res = both(policy, { ...a, ...edited, seal });
101
101
  expect(res.keygen).toBe(false);
102
102
  expect(res.ours).toMatch(/does not verify for alice/);
@@ -137,6 +137,33 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
137
137
  });
138
138
  });
139
139
 
140
+ // #3947: seal version 2 signs the checkpoint digest as one more line, so the
141
+ // outputs a resumed run carries on with are covered, and a version 1 payload
142
+ // is unchanged.
143
+ describe("seal version 2 covers the checkpoint", () => {
144
+ test("the payload adds a checkpoint line only when the approval carries one", () => {
145
+ const a = approval({ relayedBy: "bot" });
146
+ expect(gateSealVersion(a)).toBe(1);
147
+ expect(gateSealPayload(a).toString()).toBe("migrations\nmigrations-wave-1\n\njcs1-sha256:abc\nalice\n2026-10-11T00:00:00.000Z\nrelayed-by bot");
148
+ const b = approval({ relayedBy: "bot", checkpointDigest: "jcs1-sha256:cp" });
149
+ expect(gateSealVersion(b)).toBe(2);
150
+ expect(gateSealPayload(b).toString()).toBe(`${gateSealPayload(a).toString()}\ncheckpoint jcs1-sha256:cp`);
151
+ });
152
+
153
+ test.skipIf(!hasSshKeygen)("a version 2 seal verifies, and swapping or removing the checkpoint stops it", () => {
154
+ const k = key("alice-cp", ["-t", "ed25519"]);
155
+ const a = approval({ checkpointDigest: "jcs1-sha256:cp" });
156
+ const seal = { signer: "alice", key: "SHA256:x", signature: sign(k, gateSealPayload(a)), version: 2 };
157
+ const policy = policyOf(`alice ${k.pub}\n`);
158
+ expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
159
+ for (const edited of [{ checkpointDigest: "jcs1-sha256:other" }, { checkpointDigest: undefined }]) {
160
+ const res = both(policy, { ...a, ...edited, seal });
161
+ expect(res.keygen).toBe(false);
162
+ expect(res.ours).toMatch(/does not verify for alice/);
163
+ }
164
+ });
165
+ });
166
+
140
167
  describe("the refusals that need no signature", () => {
141
168
  const signers = (over: Partial<GateSigners> = {}): GateSigners => ({ base: "a".repeat(40), signersPath: ".chant/allowed_signers", active: true, signers: [], problems: [], ...over });
142
169
 
@@ -37,9 +37,42 @@ export interface SealedGateApproval {
37
37
  /** Who relayed the approval for `resolvedBy` (#3402), when someone did. */
38
38
  relayedBy?: string;
39
39
  timestamp: string;
40
+ /** The checkpoint the approval lets a run resume from (#3947), when it carries one. */
41
+ checkpointDigest?: string;
42
+ /** The line a carried approval came from (#3949): the names and time its seal was made under. */
43
+ carriedFrom?: { op: string; gate: string; timestamp: string; checkpointDigest?: string };
40
44
  seal?: unknown;
41
45
  }
42
46
 
47
+ /**
48
+ * The approval as it was sealed (#3949). A carried approval is recorded
49
+ * under a gate's new name and the time it was carried, and its seal was
50
+ * made over the line it came from, so its op, gate, time and checkpoint
51
+ * digest are read off `carriedFrom`. Any other approval is returned as it is.
52
+ */
53
+ export function sealedFieldsOf(a: SealedGateApproval): SealedGateApproval {
54
+ const from = a.carriedFrom;
55
+ if (from === undefined) return a;
56
+ const { carriedFrom: _from, checkpointDigest: _checkpoint, ...rest } = a;
57
+ return {
58
+ ...rest,
59
+ op: from.op,
60
+ gate: from.gate,
61
+ timestamp: from.timestamp,
62
+ ...(from.checkpointDigest !== undefined ? { checkpointDigest: from.checkpointDigest } : {}),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The version of a seal over `a` (#3947): 2 when the approval carries a
68
+ * checkpoint digest, which the payload signs, and 1 otherwise. `chant approve
69
+ * --sign` records 2 on the seal; a seal records no version for 1.
70
+ */
71
+ export function gateSealVersion(a: Pick<SealedGateApproval, "checkpointDigest" | "carriedFrom">): 1 | 2 {
72
+ const checkpoint = a.carriedFrom !== undefined ? a.carriedFrom.checkpointDigest : a.checkpointDigest;
73
+ return checkpoint !== undefined ? 2 : 1;
74
+ }
75
+
43
76
  /**
44
77
  * The bytes a gate approval's seal signs (#3163): the op (or component), the
45
78
  * gate, the environment, the plan digest, the approver and the time, joined
@@ -48,10 +81,21 @@ export interface SealedGateApproval {
48
81
  * `relayed-by <principal>`, so the relay can't be added, changed or removed
49
82
  * after sealing; an approval nobody relayed signs the six lines it always did,
50
83
  * so seals made before #3402 still verify.
84
+ *
85
+ * Seal version 2 (#3947) adds the checkpoint a resumed run carries its step
86
+ * outputs from: an approval with a `checkpointDigest` signs one more line,
87
+ * `checkpoint <digest>`, last. The values the steps after the gate read are
88
+ * then covered by the seal too, and removing or swapping the digest stops it
89
+ * verifying. An approval with none signs what a version 1 seal signed.
90
+ *
91
+ * A carried approval (#3949) signs what the line it came from signed: see
92
+ * {@link sealedFieldsOf}.
51
93
  */
52
- export function gateSealPayload(a: SealedGateApproval): Buffer {
94
+ export function gateSealPayload(approval: SealedGateApproval): Buffer {
95
+ const a = sealedFieldsOf(approval);
53
96
  const relay = a.relayedBy !== undefined ? `\nrelayed-by ${a.relayedBy}` : "";
54
- return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}`, "utf-8");
97
+ const checkpoint = gateSealVersion(a) === 2 ? `\ncheckpoint ${a.checkpointDigest}` : "";
98
+ return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}${checkpoint}`, "utf-8");
55
99
  }
56
100
 
57
101
  // ── SSHSIG ───────────────────────────────────────────────────────────────
@@ -32,7 +32,7 @@ import type { Signer } from "./trust-files";
32
32
  import { sshKeygen, verifySshSignature } from "./ssh-commit";
33
33
  import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./gate-seal";
34
34
 
35
- export { GATE_SEAL_NAMESPACE, gateSealPayload, type SealedGateApproval } from "./gate-seal";
35
+ export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "./gate-seal";
36
36
 
37
37
  /** The ssh signature namespace of a verdict seal. */
38
38
  export const REVIEW_SEAL_NAMESPACE = "chant-review";