@intentius/chant 0.105.1 → 0.106.1

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 (93) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.d.ts.map +1 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/pr.d.ts +16 -2
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/promote.d.ts.map +1 -1
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +7 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/changed-files.d.ts +8 -2
  13. package/dist/components/changed-files.d.ts.map +1 -1
  14. package/dist/components/fan-out-record.d.ts +24 -0
  15. package/dist/components/fan-out-record.d.ts.map +1 -1
  16. package/dist/components/pr-pipeline.d.ts +60 -4
  17. package/dist/components/pr-pipeline.d.ts.map +1 -1
  18. package/dist/components/pr-run.d.ts +92 -3
  19. package/dist/components/pr-run.d.ts.map +1 -1
  20. package/dist/gated-waves.d.ts +4 -1
  21. package/dist/gated-waves.d.ts.map +1 -1
  22. package/dist/lexicon.d.ts +13 -2
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/op/gate-name.d.ts +7 -0
  26. package/dist/op/gate-name.d.ts.map +1 -1
  27. package/dist/op/gate-summary.d.ts +2 -0
  28. package/dist/op/gate-summary.d.ts.map +1 -1
  29. package/dist/op/gate.d.ts +10 -2
  30. package/dist/op/gate.d.ts.map +1 -1
  31. package/dist/op/index.d.ts +2 -2
  32. package/dist/op/index.d.ts.map +1 -1
  33. package/dist/op/local-executor.d.ts.map +1 -1
  34. package/dist/op/local-output.d.ts +2 -2
  35. package/dist/op/local-output.d.ts.map +1 -1
  36. package/dist/op/runtime.d.ts +19 -2
  37. package/dist/op/runtime.d.ts.map +1 -1
  38. package/dist/op/runtimes/local.d.ts.map +1 -1
  39. package/dist/pr-loop.d.ts +29 -4
  40. package/dist/pr-loop.d.ts.map +1 -1
  41. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  42. package/dist/workspace/member-pipeline.d.ts +14 -1
  43. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  46. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  47. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  48. package/src/change-set.test.ts +7 -8
  49. package/src/cli/commands/lint.ts +34 -1
  50. package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
  51. package/src/cli/handlers/build.ts +65 -0
  52. package/src/cli/handlers/fan-out.test.ts +12 -1
  53. package/src/cli/handlers/fan-out.ts +7 -3
  54. package/src/cli/handlers/operator.ts +1 -2
  55. package/src/cli/handlers/pr.ts +220 -35
  56. package/src/cli/handlers/promote.test.ts +15 -1
  57. package/src/cli/handlers/promote.ts +5 -3
  58. package/src/cli/handlers/run.test.ts +164 -1
  59. package/src/cli/handlers/run.ts +22 -11
  60. package/src/cli/main.ts +13 -1
  61. package/src/cli/mcp/never-over-mcp.test.ts +74 -0
  62. package/src/cli/registry.ts +7 -0
  63. package/src/components/changed-files.test.ts +58 -0
  64. package/src/components/changed-files.ts +19 -4
  65. package/src/components/fan-out-record.test.ts +47 -2
  66. package/src/components/fan-out-record.ts +53 -0
  67. package/src/components/pr-pipeline.ts +89 -6
  68. package/src/components/pr-run.test.ts +300 -3
  69. package/src/components/pr-run.ts +196 -5
  70. package/src/gated-waves.test.ts +11 -0
  71. package/src/gated-waves.ts +7 -2
  72. package/src/lexicon.ts +13 -1
  73. package/src/lifecycle/gate-ledger.ts +4 -3
  74. package/src/op/gate-name.ts +17 -0
  75. package/src/op/gate-summary.test.ts +12 -0
  76. package/src/op/gate-summary.ts +3 -1
  77. package/src/op/gate.test.ts +15 -1
  78. package/src/op/gate.ts +21 -4
  79. package/src/op/index.ts +2 -2
  80. package/src/op/local-executor.test.ts +35 -0
  81. package/src/op/local-executor.ts +2 -2
  82. package/src/op/local-output.test.ts +20 -0
  83. package/src/op/local-output.ts +4 -4
  84. package/src/op/runtime.ts +20 -2
  85. package/src/op/runtimes/local.test.ts +15 -0
  86. package/src/op/runtimes/local.ts +3 -1
  87. package/src/pr-loop.test.ts +41 -1
  88. package/src/pr-loop.ts +38 -8
  89. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  90. package/src/workspace/lineage-upgrade.test.ts +40 -0
  91. package/src/workspace/member-pipeline.test.ts +28 -0
  92. package/src/workspace/member-pipeline.ts +20 -3
  93. package/src/workspace/pr-report.schema.json +14 -1
@@ -18,9 +18,22 @@
18
18
  * it against the new value. A change that must carry a moved output through
19
19
  * its dependents in one rollout is what gated waves are for (#3049): each
20
20
  * wave is planned after the one before it applied.
21
+ *
22
+ * ## Finishing an apply that failed partway
23
+ *
24
+ * {@link resumePrSet} (#3464) is fan-out's resume (#2417, #3049) for a pull
25
+ * request. The attempt record (`./fan-out-record.ts`) keeps the change set
26
+ * the gate approved and the components that applied. A re-run on the same
27
+ * commit narrows the derivation to what is left (`remainingFanOut`), plans
28
+ * only that against the outputs the approved plans read, and checks each
29
+ * member it plans against the approved set. When every one is covered, the
30
+ * gate is decided against the approved digest, which still stands, and only
31
+ * the rest applies. Planning everything again would give the applied members
32
+ * empty plans and the set a new digest, and so ask for a second approval of
33
+ * work already approved.
21
34
  */
22
35
 
23
- import { composeChangeSet, type ChangeSetDocument, type ChangeSetPart } from "../change-set";
36
+ import { composeChangeSet, verifyChangeSetDigest, type ChangeSetDocument, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
24
37
  import { evaluateGate, type GateCheck, type GateLedgerPort } from "../op/gate";
25
38
  import {
26
39
  memberCounts,
@@ -32,10 +45,11 @@ import {
32
45
  type PrReport,
33
46
  type PrStatus,
34
47
  } from "../pr-loop";
35
- import { deployContext, type DriverComponent } from "./driver";
48
+ import { deployContext, type DriverComponent, type DriverComponentResult } from "./driver";
36
49
  import { deployUnits } from "./deploy-units";
37
50
  import { runFanOut, type FanOutRunResult } from "./fan-out-run";
38
- import type { ComponentChangeSignal, FanOutPlan } from "./fan-out";
51
+ import { remainingFanOut, type ComponentChangeSignal, type FanOutPlan } from "./fan-out";
52
+ import type { PrApplyRecord } from "./fan-out-record";
39
53
  import type { CapabilityRegistry } from "./capability";
40
54
  import type { RunProgressEvent } from "./run-progress";
41
55
  import { planWaveComponent, readComponentOutputs } from "./wave-plan";
@@ -189,9 +203,17 @@ export interface PrApplyOptions {
189
203
  registry: CapabilityRegistry;
190
204
  env: string;
191
205
  vars?: Record<string, unknown>;
192
- /** The set {@link planPrSet} planned and the gate approved. */
206
+ /** The set {@link planPrSet} planned and the gate approved, or {@link resumePrSet}'s set. */
193
207
  set: PrPlanSet;
194
208
  onProgress?: (event: RunProgressEvent) => void;
209
+ /**
210
+ * What an earlier attempt at this approved set applied (#3464): its
211
+ * components, which are reported applied and not run again, and the
212
+ * outputs they left.
213
+ */
214
+ resumed?: { completed: string[]; outputs: Record<string, Record<string, unknown>> };
215
+ /** Called as each component settles, so an attempt record survives a kill (`runFanOut`). */
216
+ onComponentSettled?: (result: DriverComponentResult, outputs: Record<string, unknown> | undefined) => void;
195
217
  }
196
218
 
197
219
  const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
@@ -208,8 +230,19 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
208
230
  plans: options.set.plans,
209
231
  componentOutputs: { ...before },
210
232
  ...(options.onProgress ? { onProgress: options.onProgress } : {}),
233
+ ...(options.onComponentSettled ? { onComponentSettled: options.onComponentSettled } : {}),
211
234
  });
212
235
  const completed = new Set(run.completed);
236
+ const priorCompleted = new Set(options.resumed?.completed ?? []);
237
+ /**
238
+ * Whether a dependency's outputs moved from what the plans read: by this
239
+ * run's apply, or by the earlier attempt's when the record kept them.
240
+ */
241
+ const movedBy = (dep: string): boolean => {
242
+ if (completed.has(dep)) return !sameJson(before[dep], run.componentOutputs[dep]);
243
+ const earlier = options.resumed?.outputs;
244
+ return priorCompleted.has(dep) && earlier !== undefined && dep in earlier && !sameJson(before[dep], earlier[dep]);
245
+ };
213
246
  const blocked = new Set(run.blocked.map((b) => b.component));
214
247
  const errorOf = (component: string): string | undefined => {
215
248
  const result = run.results.find((r) => r.component === component);
@@ -218,9 +251,10 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
218
251
  };
219
252
  const byName = new Map(options.components.map((c) => [c.name, c]));
220
253
  const members = options.set.members.map((m): PrMember => {
254
+ if (priorCompleted.has(m.component)) return { ...m, status: "applied" };
221
255
  if (completed.has(m.component)) {
222
256
  const moved = (byName.get(m.component)?.dependsOn ?? [])
223
- .filter((dep) => completed.has(dep) && !sameJson(before[dep], run.componentOutputs[dep]))
257
+ .filter(movedBy)
224
258
  .sort();
225
259
  return { ...m, status: "applied", ...(moved.length > 0 ? { inputsMoved: moved } : {}) };
226
260
  }
@@ -231,10 +265,163 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
231
265
  return { members, status: run.status === "ok" ? "applied" : "failed", run };
232
266
  }
233
267
 
268
+ const entryKey = (e: Pick<ChangeSetEntry, "member" | "address" | "deposed">): string => `${e.member}\u0000${e.address}\u0000${e.deposed ?? ""}`;
269
+
270
+ /**
271
+ * Why `member`'s fresh plan is not covered by the approved change set, or
272
+ * `undefined` when it is. A member left over from a partial apply is covered
273
+ * when it plans to the digest that was approved, or when every change it
274
+ * plans now is one the approved plan made:
275
+ *
276
+ * - an entry at the same address with the same action, or a `create` or a
277
+ * `delete` where a `replace` was approved (the other half applied);
278
+ * - each attribute it writes was written by the approved entry, to the same
279
+ * value or to one the approved plan knew only after apply. A sensitive
280
+ * attribute carries no value to compare, so it is covered only where the
281
+ * approved entry wrote that attribute as sensitive too. On a `create` left
282
+ * from a `replace`, an attribute the replace did not change is not compared;
283
+ * - each side effect is one the approved plan ran.
284
+ *
285
+ * A member with holes the approved plan did not have is never covered by its
286
+ * entries, since its entries may not say everything it will do.
287
+ */
288
+ export function planCoveredBy(approved: ChangeSetDocument, fresh: ChangeSetDocument, member: string): string | undefined {
289
+ const was = approved.members.find((m) => m.member === member);
290
+ const now = fresh.members.find((m) => m.member === member);
291
+ if (!now) return undefined;
292
+ if (!was) return `${member} was not in the approved plan`;
293
+ if (now.status === "failed" || now.planDigest === null) return `${member} failed to plan${now.error ? `: ${now.error}` : ""}`;
294
+ if (was.planDigest !== null && now.planDigest === was.planDigest) return undefined;
295
+ if (was.status === "failed") return `${member} had no approved plan`;
296
+ const knownHoles = new Set(was.holes.map((h) => h.address));
297
+ const newHole = now.holes.find((h) => !knownHoles.has(h.address));
298
+ if (newHole) return `${member} cannot read ${newHole.address} (${newHole.reason}), so its plan cannot be checked against the approved one`;
299
+
300
+ const approvedEntries = new Map(approved.entries.filter((e) => e.member === member).map((e) => [entryKey(e), e]));
301
+ for (const e of fresh.entries) {
302
+ if (e.member !== member || e.action === "no-op" || e.action === "read") continue;
303
+ // A create-before-destroy replace whose create applied leaves the old
304
+ // object deposed, and the plan now deletes it under a deposed key.
305
+ const a =
306
+ approvedEntries.get(entryKey(e)) ??
307
+ (e.action === "delete" && e.deposed ? approvedEntries.get(entryKey({ ...e, deposed: undefined })) : undefined);
308
+ if (!a) return `${member} now plans to ${e.action} ${e.address}, which the approved plan did not`;
309
+ const halfReplace = a.action === "replace" && (e.action === "create" || e.action === "delete");
310
+ if (a.action !== e.action && !halfReplace) {
311
+ return `${member} now plans to ${e.action} ${e.address}, where the approved plan would ${a.action} it`;
312
+ }
313
+ const approvedAttrs = new Map(a.attributes.map((attr) => [attr.path, attr]));
314
+ for (const attr of e.attributes) {
315
+ const at = `${e.address}.${attr.path}`;
316
+ const prior = approvedAttrs.get(attr.path);
317
+ if (!prior) {
318
+ if (halfReplace && e.action === "create") continue;
319
+ return `${member} now writes ${at}, which the approved plan did not`;
320
+ }
321
+ if (attr.sensitive) {
322
+ if (!prior.sensitive) return `${member} now writes ${at} as sensitive, so it cannot be compared with the approved value`;
323
+ continue;
324
+ }
325
+ if (prior.sensitive) return `${member} writes ${at}, which the approved plan wrote as sensitive, so it cannot be compared`;
326
+ if (prior.unknown) continue;
327
+ if (attr.unknown) return `${member} now writes ${at} with a value known only after apply, where the approved plan knew it`;
328
+ if (!sameJson(attr.after, prior.after)) return `${member} now writes ${at} to a different value than the approved plan`;
329
+ }
330
+ }
331
+ const approvedEffects = new Set(
332
+ (approved.sideEffects ?? []).filter((s) => s.member === member).map((s) => JSON.stringify([s.address, s.type, s.trigger ?? null, s.event ?? null])),
333
+ );
334
+ for (const s of fresh.sideEffects ?? []) {
335
+ if (s.member !== member) continue;
336
+ if (!approvedEffects.has(JSON.stringify([s.address, s.type, s.trigger ?? null, s.event ?? null]))) {
337
+ return `${member} now runs ${s.address}, which the approved plan did not`;
338
+ }
339
+ }
340
+ return undefined;
341
+ }
342
+
343
+ export interface PrResumeOptions {
344
+ components: DriverComponent[];
345
+ /** The fan-out the change derives now. The caller has checked it is the one the record was made from. */
346
+ plan: FanOutPlan;
347
+ registry: CapabilityRegistry;
348
+ env: string;
349
+ vars?: Record<string, unknown>;
350
+ seededOutputs?: Record<string, Record<string, unknown>>;
351
+ /** The approved set, from the attempt record. */
352
+ record: PrApplyRecord;
353
+ /** Components an earlier attempt applied. */
354
+ completed: string[];
355
+ }
356
+
357
+ export type PrResume =
358
+ | {
359
+ ok: true;
360
+ /** The derivation narrowed to what is left. */
361
+ plan: FanOutPlan;
362
+ /** The approved set, carrying fresh plans for the members left. Its digest is the approved one. */
363
+ set: PrPlanSet;
364
+ /** What the fresh plans of the members left came to. */
365
+ fresh: PrPlanSet;
366
+ }
367
+ | {
368
+ ok: false;
369
+ /** Why the approved set does not cover what is left, one line per member. */
370
+ reasons: string[];
371
+ fresh?: PrPlanSet;
372
+ };
373
+
374
+ /**
375
+ * Plan what an apply that failed partway left (#3464): narrow the plan by
376
+ * the components that applied, plan the rest against the outputs the
377
+ * approved plans read, and check each member against the approved change
378
+ * set ({@link planCoveredBy}). Covered, the result is the approved set with
379
+ * the new plans in it, to decide the gate against `record.digest` and hand
380
+ * to {@link applyPrSet}. Not covered, it says why, and the caller plans
381
+ * everything and asks for a fresh approval.
382
+ *
383
+ * The record now travels between CI jobs through a cache (#3543), so its
384
+ * change set has to be the one its digest names: the document's own digest
385
+ * is `record.digest`, and its members' plan digests give that digest.
386
+ */
387
+ export async function resumePrSet(options: PrResumeOptions): Promise<PrResume> {
388
+ const { record } = options;
389
+ if (record.changeSet.digest !== record.digest || !verifyChangeSetDigest(record.changeSet)) {
390
+ return { ok: false, reasons: [`the record's change set is not the one its digest ${record.digest} names`] };
391
+ }
392
+ const plan = remainingFanOut(options.plan, options.components, { completed: options.completed });
393
+ const fresh = await planPrSet({
394
+ components: options.components,
395
+ plan,
396
+ registry: options.registry,
397
+ env: options.env,
398
+ ...(options.vars ? { vars: options.vars } : {}),
399
+ seededOutputs: { ...(options.seededOutputs ?? {}), ...options.record.planOutputs },
400
+ });
401
+ const reasons = fresh.doc.members
402
+ .map((m) => planCoveredBy(options.record.changeSet, fresh.doc, m.member))
403
+ .filter((r): r is string => r !== undefined);
404
+ if (reasons.length > 0) return { ok: false, reasons, fresh };
405
+ return {
406
+ ok: true,
407
+ plan,
408
+ fresh,
409
+ set: {
410
+ doc: options.record.changeSet,
411
+ members: options.record.members.map((m) => ({ ...m, status: "planned" as const })),
412
+ plans: fresh.plans,
413
+ outputs: options.record.planOutputs,
414
+ failed: false,
415
+ },
416
+ };
417
+ }
418
+
234
419
  export interface PrReportInput {
235
420
  stage: "plan" | "apply";
236
421
  pr: number | null;
237
422
  env: string;
423
+ /** The workspace member whose pipeline runs the stage (#3465). */
424
+ member?: string;
238
425
  base: string;
239
426
  head: string;
240
427
  op: string;
@@ -247,6 +434,8 @@ export interface PrReportInput {
247
434
  members?: PrMember[];
248
435
  refusal?: PrRefusal;
249
436
  message?: string;
437
+ /** Components an earlier attempt at the approved set applied (#3464). */
438
+ resumed?: string[];
250
439
  }
251
440
 
252
441
  /** The report a stage writes. */
@@ -258,6 +447,7 @@ export function prReport(input: PrReportInput): PrReport {
258
447
  stage: input.stage,
259
448
  pr: input.pr,
260
449
  env: input.env,
450
+ ...(input.member ? { member: input.member } : {}),
261
451
  base: input.base,
262
452
  head: input.head,
263
453
  op: input.op,
@@ -266,6 +456,7 @@ export function prReport(input: PrReportInput): PrReport {
266
456
  status: input.status,
267
457
  ...(input.refusal ? { refusal: input.refusal } : {}),
268
458
  ...(input.message ? { message: input.message } : {}),
459
+ ...(input.resumed ? { resumed: { applied: [...input.resumed].sort() } } : {}),
269
460
  approval: input.approval,
270
461
  selection: {
271
462
  changed: input.plan.order.filter((c) => changed.has(c)),
@@ -115,4 +115,15 @@ describe("wave records", () => {
115
115
  expect(line).toContain("nothing in it was applied");
116
116
  expect(line).toContain("chant approve fan-out release-wave-2 --plan");
117
117
  });
118
+
119
+ test("the approve hint on an unsealed gate has no --sign", () => {
120
+ const line = describeChangedWave(record);
121
+ expect(line).not.toContain("--sign");
122
+ expect(line.endsWith(`--plan ${record.digest}`)).toBe(true);
123
+ });
124
+
125
+ test("the approve hint on a sealed gate ends in --sign", () => {
126
+ const line = describeChangedWave(record, true);
127
+ expect(line.endsWith(`--plan ${record.digest} --sign`)).toBe(true);
128
+ });
118
129
  });
@@ -230,11 +230,16 @@ export function withWaveRecord(records: readonly WaveRecord[], record: WaveRecor
230
230
  /**
231
231
  * The refusal for a wave whose set changed after it was approved: the digest
232
232
  * that was approved, the one planned now, and that nothing in the wave ran.
233
+ * On a sealed gate the approve hint ends in `--sign`.
233
234
  */
234
- export function describeChangedWave(record: Pick<WaveRecord, "wave" | "op" | "gate" | "digest" | "approved">): string {
235
+ export function describeChangedWave(
236
+ record: Pick<WaveRecord, "wave" | "op" | "gate" | "digest" | "approved">,
237
+ /** True when the gate is sealed (`identity.gates`): an approval without `--sign` doesn't count, so the hint says to sign. */
238
+ sealed = false,
239
+ ): string {
235
240
  return (
236
241
  `wave ${record.wave} changed after it was approved, so nothing in it was applied. ` +
237
242
  `approved: ${record.approved ?? "(none)"}; planned now: ${record.digest}. ` +
238
- `Read the new plan, then approve it: chant approve ${record.op} ${record.gate} --plan ${record.digest}`
243
+ `Read the new plan, then approve it: chant approve ${record.op} ${record.gate} --plan ${record.digest}${sealed ? " --sign" : ""}`
239
244
  );
240
245
  }
package/src/lexicon.ts CHANGED
@@ -41,6 +41,7 @@ export type {
41
41
  OpRunHandle,
42
42
  OpRunRecord,
43
43
  OpRunStartOptions,
44
+ OpGateResolveOptions,
44
45
  OpRunState,
45
46
  OpRunStatus,
46
47
  } from "./op/runtime";
@@ -562,6 +563,12 @@ export interface ComponentPipelineOptions {
562
563
  * GitLab's job rules) are limited to the member's files and the pipeline
563
564
  * file. Its jobs run in the member's directory, and its names carry the
564
565
  * member's name so two members' pipelines never collide.
566
+ *
567
+ * A pull-request pipeline (`prLoop`, #3465) takes no path filter: a change
568
+ * outside the member can reach it (a module it calls in another member),
569
+ * so its plan runs on every pull request and selects from the whole
570
+ * change. Its commands pass `--member`, which gives the member its own
571
+ * gate op, note and statuses on the pull request.
565
572
  */
566
573
  member?: PipelineMember;
567
574
  }
@@ -913,7 +920,12 @@ export interface ChangedUnitsContext {
913
920
  projectRoot: string;
914
921
  /** The resolved project configuration, for the lexicon's own namespace. */
915
922
  config: Record<string, unknown>;
916
- /** Files the change added, modified or deleted, relative to `projectRoot`, with `/` separators. */
923
+ /**
924
+ * Files the change added, modified or deleted, relative to `projectRoot`,
925
+ * with `/` separators. Files outside `projectRoot` are listed too, as
926
+ * `../...`, so a unit that calls code outside the project (another
927
+ * workspace member's module, #3465) can claim them.
928
+ */
917
929
  changedFiles: string[];
918
930
  }
919
931
 
@@ -41,7 +41,7 @@
41
41
  * (absent on the resolution lines written before #2119, which is why
42
42
  * `"resolution"` is the default reading).
43
43
  */
44
- import { FAN_OUT_GATE_OP } from "../op/gate-name";
44
+ import { isNeverOverMcpGateOp } from "../op/gate-name";
45
45
  import { samePlanDigest } from "./plan-digest";
46
46
  import { sortedJsonReplacer } from "../utils";
47
47
  import { currentGateOrigin, type GateOrigin } from "./gate-origin";
@@ -342,8 +342,9 @@ export async function appendPendingGate(
342
342
  kind: "pending",
343
343
  origin: currentGateOrigin(),
344
344
  // chant#3485: an Op MCP cannot discover declares nothing, so the gate
345
- // records the rule for it. Every fan-out gate is one.
346
- ...(input.op === FAN_OUT_GATE_OP ? { neverOverMcp: true as const } : {}),
345
+ // records the rule for it: fan-out, workspace-upgrade (chant#3513) and
346
+ // the pull-request gate's `pr-<number>` op.
347
+ ...(isNeverOverMcpGateOp(input.op) ? { neverOverMcp: true as const } : {}),
347
348
  ...input,
348
349
  };
349
350
  const commit = await appendGateLine(record, "Pending gate record", opts);
@@ -37,6 +37,23 @@ export const FAN_OUT_GATE_OP = "fan-out";
37
37
  */
38
38
  export const WORKSPACE_UPGRADE_GATE_OP = "workspace-upgrade";
39
39
 
40
+ /**
41
+ * The pull-request loop records its gate under `pr-<number>`, or
42
+ * `pr-<number>-<member>` in a workspace member (`prOp` in `pr-loop.ts`), and
43
+ * no `*.op.ts` stands behind that name either.
44
+ */
45
+ const PR_GATE_OP = /^pr-\d+(?:-[a-z0-9][a-z0-9-]*)?$/;
46
+
47
+ /**
48
+ * Whether gates recorded under this op name are never resolved over MCP or ACP
49
+ * (chant#3485, chant#3513). These are the gate-only names MCP cannot discover:
50
+ * `fan-out`, `workspace-upgrade` and the pull-request gate `pr-<number>`. With
51
+ * no Op config to read `neverOverMcp` from, the pending record carries it.
52
+ */
53
+ export function isNeverOverMcpGateOp(op: string): boolean {
54
+ return op === FAN_OUT_GATE_OP || op === WORKSPACE_UPGRADE_GATE_OP || PR_GATE_OP.test(op);
55
+ }
56
+
40
57
  /** Either spelling of a gate step's name. `gate` since #2202; `signalName` through 0.59.0. */
41
58
  export interface GateNamed {
42
59
  gate?: string;
@@ -107,3 +107,15 @@ describe("writeGatedRunSummary surfaces (#2243, #2256)", () => {
107
107
  expect(writeGatedRunSummary(summary, { CHANT_GATE_SUMMARY: "/no/such/dir/gate.md" })).toBeUndefined();
108
108
  });
109
109
  });
110
+
111
+ describe("gatedRunSummaryMarkdown on a sealed gate (#3521)", () => {
112
+ test("the approve line carries --sign when sealed", () => {
113
+ const md = gatedRunSummaryMarkdown({ ...summary, sealed: true });
114
+ expect(md).toContain("chant approve app-apply approve-app-apply --sign --approver <you>");
115
+ });
116
+
117
+ test("the approve line has no --sign when unsealed", () => {
118
+ expect(gatedRunSummaryMarkdown({ ...summary, sealed: false })).not.toContain("--sign");
119
+ expect(gatedRunSummaryMarkdown(summary)).not.toContain("--sign");
120
+ });
121
+ });
@@ -57,6 +57,8 @@ export interface GatedRunSummary {
57
57
  * Absent at level 0 and for the root member.
58
58
  */
59
59
  ledgerPrefix?: string;
60
+ /** Whether the workspace seals this gate, so the `chant approve` line carries `--sign`. */
61
+ sealed?: boolean;
60
62
  /** The environment a component gate's pending fact was recorded in (#2574). The `chant approve` line names it. */
61
63
  environment?: string;
62
64
  }
@@ -101,7 +103,7 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
101
103
  "Approve it, then re-run this workflow:",
102
104
  "",
103
105
  "```",
104
- `${approveCommand(summary.op, summary.gate, summary.environment)} --approver <you>`,
106
+ `${approveCommand(summary.op, summary.gate, summary.environment, undefined, summary.sealed === true)} --approver <you>`,
105
107
  "```",
106
108
  "",
107
109
  `Ledger: \`${gateLedgerPath(summary.op, summary.ledgerPrefix)}\` on the \`chant/lifecycle\` branch.`,
@@ -16,7 +16,7 @@ import { writeFileSync } from "node:fs";
16
16
  import { mkdir, rm } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
  import { tmpdir } from "node:os";
19
- import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
19
+ import { approveCommand, describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "./gate";
20
20
  import { appendGateResolution, readGateLedger } from "../lifecycle/gate-ledger";
21
21
  import {
22
22
  requireLifecycleLedger,
@@ -612,3 +612,17 @@ describe("op/gate — a rejected ledger push is reported, not swallowed (#2310)"
612
612
  expect(check.pending.gate).toBe("approve-live-apply");
613
613
  });
614
614
  });
615
+
616
+ describe("op/gate — the approve hint on a sealed gate (#3521)", () => {
617
+ test("approveCommand adds --sign last, only when sealed", () => {
618
+ expect(approveCommand("app", "ship")).toBe("chant approve app ship");
619
+ expect(approveCommand("app", "ship", "prod", "sha256:abc", true)).toBe("chant approve app ship --env prod --plan sha256:abc --sign");
620
+ expect(approveCommand("app", "ship", undefined, undefined, false)).toBe("chant approve app ship");
621
+ });
622
+
623
+ test("describeGateMismatch names --sign when sealed, and not when unsealed", () => {
624
+ const mismatch = { approved: "sha256:old", planned: "sha256:new", resolvedBy: "alex", timestamp: "2026-09-08T00:00:00Z" } as GateDigestMismatch;
625
+ expect(describeGateMismatch("app", "ship", mismatch, true)).toMatch(/chant approve app ship --sign$/);
626
+ expect(describeGateMismatch("app", "ship", mismatch)).not.toContain("--sign");
627
+ });
628
+ });
package/src/op/gate.ts CHANGED
@@ -235,7 +235,7 @@ export interface GateDigestMismatch {
235
235
  * have changed — the record simply never said what it approved, which is
236
236
  * every record written before #2300.
237
237
  */
238
- export function describeGateMismatch(op: string, gate: string, mismatch: GateDigestMismatch): string {
238
+ export function describeGateMismatch(op: string, gate: string, mismatch: GateDigestMismatch, sealed = false): string {
239
239
  const why =
240
240
  mismatch.approved === undefined
241
241
  ? mismatch.environment !== undefined
@@ -249,7 +249,7 @@ export function describeGateMismatch(op: string, gate: string, mismatch: GateDig
249
249
  `Gate "${gate}" is approved, but not for this plan. ` +
250
250
  `approved: ${describePlanDigest(mismatch.approved)} (by ${mismatch.resolvedBy} at ${mismatch.timestamp}); ` +
251
251
  `planned: ${mismatch.planned}. ` +
252
- `${why} ${approveCommand(op, gate, mismatch.environment)}`
252
+ `${why} ${approveCommand(op, gate, mismatch.environment, undefined, sealed)}`
253
253
  );
254
254
  }
255
255
 
@@ -512,8 +512,25 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
512
512
  * plan the reader was shown and not whatever is pending by the time they run
513
513
  * it.
514
514
  */
515
- export function approveCommand(op: string, gate: string, environment?: string, planDigest?: string): string {
515
+ export function approveCommand(op: string, gate: string, environment?: string, planDigest?: string, sealed = false): string {
516
516
  return `chant approve ${op} ${gate}` +
517
517
  (environment === undefined ? "" : ` --env ${environment}`) +
518
- (planDigest === undefined ? "" : ` --plan ${planDigest}`);
518
+ (planDigest === undefined ? "" : ` --plan ${planDigest}`) +
519
+ (sealed ? " --sign" : "");
520
+ }
521
+
522
+ /**
523
+ * Whether the workspace seals `gate` (`identity.gates`), so an approval
524
+ * without `--sign` doesn't count. Callers pass the answer to
525
+ * {@link approveCommand} so the hint names the flag. Loaded on demand, as the
526
+ * git ledger port does: the workspace modules import the Op modules. A read
527
+ * that throws counts as unsealed, since the hint is advice and not the check.
528
+ */
529
+ export async function gateIsSealed(gate: string, cwd: string = process.cwd()): Promise<boolean> {
530
+ try {
531
+ const { gateAdmission } = await import("../workspace/identity");
532
+ return gateAdmission(cwd, gate) !== null;
533
+ } catch {
534
+ return false;
535
+ }
519
536
  }
package/src/op/index.ts CHANGED
@@ -62,7 +62,7 @@ export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
62
62
  export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
63
63
  export { currentOpRun, withOpRunContext } from "./run-context";
64
64
  export type { OpRunContext, PassedGate } from "./run-context";
65
- export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch } from "./gate";
65
+ export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch, gateIsSealed } from "./gate";
66
66
  export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch, GateQuorumProgress, GateTally } from "./gate";
67
67
  export { tallyGateApprovals, approverOf } from "./gate";
68
68
  export {
@@ -80,7 +80,7 @@ export type { GateNamed } from "./gate-name";
80
80
  export { createLocalOpRuntime } from "./runtimes/local";
81
81
  export { runStateOf } from "./runtime";
82
82
  export type {
83
- OpRuntimeProvider, OpRunHandle, OpRunStartOptions, OpRunState, OpRunStatus,
83
+ OpRuntimeProvider, OpRunHandle, OpRunStartOptions, OpGateResolveOptions, OpRunState, OpRunStatus,
84
84
  OpRunRecord, OpRunRecordInput, OpRunPhaseRecord, OpRunStepRecord,
85
85
  } from "./runtime";
86
86
  export { buildOpIR, serializeOpIR, opConfigFromIR, OP_IR_FORMAT_VERSION } from "./op-ir";
@@ -9,6 +9,14 @@ import {
9
9
  import { memoryGateLedgerPort, type GateLedgerPort } from "./gate";
10
10
  import { computePlanDigest } from "../lifecycle/plan-digest";
11
11
  import { renderHuman } from "./local-output";
12
+
13
+ // #3521: whether the workspace seals a gate is read from identity.gates in the
14
+ // working directory; a test says which gates are sealed instead.
15
+ const sealedGates = new Set<string>();
16
+ vi.mock("./gate", async () => {
17
+ const actual = await vi.importActual<typeof import("./gate")>("./gate");
18
+ return { ...actual, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
19
+ });
12
20
  import type { GateResolutionRecord, PendingGateRecord, PendingGateInput } from "../lifecycle/gate-ledger";
13
21
  import { stepOutput } from "./step-output-ref";
14
22
 
@@ -778,6 +786,33 @@ describe("runOpLocally — a gate approves a plan, not the next run (#2300)", ()
778
786
  expect(lines.some((l) => l.includes("[refused]") && l.includes("not for this plan"))).toBe(true);
779
787
  });
780
788
 
789
+ describe("the refusal's approve command (#3521)", () => {
790
+ async function refusalOnRename(): Promise<string | undefined> {
791
+ const { root, activities, config, digestOf } = harness();
792
+ const gates = approvableLedger();
793
+ await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW });
794
+ gates.approve("approve-live-apply", digestOf("aws_s3_bucket.original"), "2026-09-09T12:05:00.000Z");
795
+ root.address = "aws_s3_bucket.renamed";
796
+ const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-09-09T12:10:00.000Z" });
797
+ return second.records.find((r) => r.fn === "gate:approve-live-apply")?.refusal;
798
+ }
799
+
800
+ test("unsealed gate: the refusal's approve command has no --sign", async () => {
801
+ const refusal = await refusalOnRename();
802
+ expect(refusal).toContain("chant approve live-apply approve-live-apply");
803
+ expect(refusal).not.toContain("--sign");
804
+ });
805
+
806
+ test("sealed gate: the refusal's approve command ends in --sign", async () => {
807
+ sealedGates.add("approve-live-apply");
808
+ try {
809
+ expect(await refusalOnRename()).toContain("chant approve live-apply approve-live-apply --sign");
810
+ } finally {
811
+ sealedGates.clear();
812
+ }
813
+ });
814
+ });
815
+
781
816
  test("approve, re-run with the root unchanged — it applies", async () => {
782
817
  const { applied, activities, config, digestOf } = harness();
783
818
  const gates = approvableLedger();
@@ -26,7 +26,7 @@ import type { ReceiptReadResult } from "./receipt-store";
26
26
  import { isStepOutputRef } from "./step-output-ref";
27
27
  import { parseDuration } from "./duration";
28
28
  import { stepTimeoutProblem } from "./activity-profiles";
29
- import { describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
29
+ import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
30
30
  import { gateName } from "./gate-name";
31
31
  import type { ResolvedGateApproval } from "./gate-approval";
32
32
  import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
@@ -672,7 +672,7 @@ async function runGateStep(
672
672
  }
673
673
 
674
674
  const refusal = check.mismatch
675
- ? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch) }
675
+ ? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch, await gateIsSealed(gateName(step))) }
676
676
  : {};
677
677
  return {
678
678
  record: {
@@ -207,3 +207,23 @@ describe("renderJson", () => {
207
207
  expect(parsed.pushed).toBeUndefined();
208
208
  });
209
209
  });
210
+
211
+ describe("a sealed gate's approve line carries --sign (#3521)", () => {
212
+ test("renderHuman prints --sign when sealed, and not when unsealed", () => {
213
+ const sealed: string[] = [];
214
+ renderHuman(GATED_WITH_PLAN, (l) => sealed.push(l), true);
215
+ expect(sealed.join("\n")).toContain(`approve : chant approve prod-apply rollout-gate --plan ${GATED_WITH_PLAN.gate!.planDigest} --sign`);
216
+ const unsealed: string[] = [];
217
+ renderHuman(GATED_WITH_PLAN, (l) => unsealed.push(l), false);
218
+ expect(unsealed.join("\n")).not.toContain("--sign");
219
+ });
220
+
221
+ test("renderJson's approve field carries --sign when sealed, and not when unsealed", () => {
222
+ const sealed: string[] = [];
223
+ renderJson(GATED, (l) => sealed.push(l), true);
224
+ expect(JSON.parse(sealed[0]).approve).toBe("chant approve prod-apply rollout-gate --sign");
225
+ const unsealed: string[] = [];
226
+ renderJson(GATED, (l) => unsealed.push(l), false);
227
+ expect(JSON.parse(unsealed[0]).approve).toBe("chant approve prod-apply rollout-gate");
228
+ });
229
+ });
@@ -26,7 +26,7 @@ function formatDuration(ms: number): string {
26
26
  * Render a run result as human-readable progress. Defaults to stderr so stdout
27
27
  * stays clean for piping (the `--json` renderer owns stdout).
28
28
  */
29
- export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
29
+ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed = false): void {
30
30
  let currentPhase: string | undefined;
31
31
  for (const record of result.records) {
32
32
  if (record.phase !== currentPhase) {
@@ -100,7 +100,7 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
100
100
  // #2300: the plan the approval will be bound to. Printed before the
101
101
  // command, because it is what the command approves.
102
102
  if (gate.planDigest) write(` plan : ${gate.planDigest}`);
103
- write(` approve : ${approveCommand(gate.op, gate.gate, undefined, gate.planDigest)}`);
103
+ write(` approve : ${approveCommand(gate.op, gate.gate, undefined, gate.planDigest, sealed)}`);
104
104
  if (gate.url) write(` approve at: ${gate.url}`);
105
105
  write(` expires : ${gate.expiresAt}`);
106
106
  // #2310: this run's own append reached only the local chant/lifecycle
@@ -127,8 +127,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
127
127
  * stopped on; the `approve` command line rides alongside so a CI job reporting
128
128
  * the gate doesn't have to reassemble it from the op and gate names.
129
129
  */
130
- export function renderJson(result: OpRunResult, write: Writer = stdout): void {
131
- const approve = result.gate ? { approve: approveCommand(result.gate.op, result.gate.gate, undefined, result.gate.planDigest) } : {};
130
+ export function renderJson(result: OpRunResult, write: Writer = stdout, sealed = false): void {
131
+ const approve = result.gate ? { approve: approveCommand(result.gate.op, result.gate.gate, undefined, result.gate.planDigest, sealed) } : {};
132
132
  // #2310: whether this run's own append reached the remote — not part of
133
133
  // the persisted ledger record (a replay has nothing new to report), but a
134
134
  // live run's JSON consumer needs it exactly where the human render shows it.