@intentius/chant 0.77.0 → 0.79.0

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 (43) hide show
  1. package/dist/cli/handlers/operator.d.ts +17 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +4 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/lifecycle/gate-ledger.d.ts +22 -0
  7. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  8. package/dist/lint/rules/op/index.d.ts +2 -1
  9. package/dist/lint/rules/op/index.d.ts.map +1 -1
  10. package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
  11. package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
  12. package/dist/op/builders.d.ts +4 -0
  13. package/dist/op/builders.d.ts.map +1 -1
  14. package/dist/op/gate-approval.d.ts +142 -0
  15. package/dist/op/gate-approval.d.ts.map +1 -0
  16. package/dist/op/gate.d.ts +54 -0
  17. package/dist/op/gate.d.ts.map +1 -1
  18. package/dist/op/index.d.ts +4 -1
  19. package/dist/op/index.d.ts.map +1 -1
  20. package/dist/op/local-executor.d.ts +4 -0
  21. package/dist/op/local-executor.d.ts.map +1 -1
  22. package/dist/op/op-ir.d.ts +2 -0
  23. package/dist/op/op-ir.d.ts.map +1 -1
  24. package/dist/op/types.d.ts +7 -0
  25. package/dist/op/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/handlers/operator.test.ts +85 -0
  28. package/src/cli/handlers/operator.ts +116 -1
  29. package/src/cli/main.ts +9 -0
  30. package/src/cli/registry.ts +4 -0
  31. package/src/lifecycle/gate-ledger.ts +22 -0
  32. package/src/lint/rules/op/index.ts +4 -2
  33. package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
  34. package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
  35. package/src/op/builders.ts +10 -1
  36. package/src/op/gate-approval.test.ts +258 -0
  37. package/src/op/gate-approval.ts +258 -0
  38. package/src/op/gate.ts +148 -10
  39. package/src/op/index.ts +9 -1
  40. package/src/op/local-executor.test.ts +66 -0
  41. package/src/op/local-executor.ts +44 -1
  42. package/src/op/op-ir.ts +4 -0
  43. package/src/op/types.ts +7 -0
package/src/op/gate.ts CHANGED
@@ -45,6 +45,9 @@ import {
45
45
  type PendingGateRecord,
46
46
  } from "../lifecycle/gate-ledger";
47
47
  import { describePlanDigest } from "../lifecycle/plan-digest";
48
+ import { isModelAuthored } from "../lifecycle/gate-origin";
49
+ import { sortedJsonReplacer } from "../utils";
50
+ import type { GateApprover, ResolvedGateApproval } from "./gate-approval";
48
51
  import { pushLifecycle, requireLifecycleLedger } from "../lifecycle/git";
49
52
  import { parseDuration } from "./duration";
50
53
 
@@ -167,6 +170,13 @@ export interface GateCheckInput {
167
170
  * the standing pending fact satisfies the gate whatever has changed since.
168
171
  */
169
172
  planDigest?: string;
173
+ /**
174
+ * The gate's quorum and policy (#2508), context already resolved. Absent,
175
+ * one approval passes the gate, which is the rule every gate had before.
176
+ * Present, {@link tallyGateApprovals} decides, and the pending fact carries
177
+ * it so `chant approve` can evaluate the policy.
178
+ */
179
+ approval?: ResolvedGateApproval;
170
180
  /** ISO-8601 "now" — supplied by the caller, so the decision is deterministic under test. */
171
181
  now?: string;
172
182
  }
@@ -212,9 +222,28 @@ export function describeGateMismatch(op: string, gate: string, mismatch: GateDig
212
222
  );
213
223
  }
214
224
 
225
+ /** How far a gate with a quorum has got (#2508). */
226
+ export interface GateQuorumProgress {
227
+ /** Approvers whose approval counts toward the quorum, oldest first. */
228
+ approvers: string[];
229
+ need: number;
230
+ }
231
+
215
232
  /** Either the gate is answered, or it is a standing fact. */
216
233
  export type GateCheck =
217
- | { satisfied: true; resolution: GateResolutionRecord }
234
+ | {
235
+ satisfied: true;
236
+ /** The approval that completed the gate: the newest counted one, or the permit that passed it. */
237
+ resolution: GateResolutionRecord;
238
+ /**
239
+ * Set on a gate that declares `approval` (#2508). `"quorum"` when enough
240
+ * human approvals were recorded, `"policy"` when an `enforce`-mode permit
241
+ * passed it on its own.
242
+ */
243
+ via?: "quorum" | "policy";
244
+ /** Every approval that counted toward the quorum. Set with `via`. */
245
+ approvals?: GateResolutionRecord[];
246
+ }
218
247
  | {
219
248
  satisfied: false;
220
249
  pending: PendingGateRecord;
@@ -230,8 +259,93 @@ export type GateCheck =
230
259
  pushWarning?: string;
231
260
  /** Present when a resolution stands for this gate but for another plan (#2300). */
232
261
  mismatch?: GateDigestMismatch;
262
+ /** Present on a gate with a quorum (#2508): who has approved this plan so far. */
263
+ quorum?: GateQuorumProgress;
233
264
  };
234
265
 
266
+ /** How a recorded approval reads: its own `approver`, or for a record written before #2508, a human unless a model-authored channel wrote it. */
267
+ export function approverOf(record: GateResolutionRecord): GateApprover {
268
+ if (record.approver) return record.approver;
269
+ return { kind: isModelAuthored(record.origin) ? "agent" : "human" };
270
+ }
271
+
272
+ /** What {@link tallyGateApprovals} found. */
273
+ export interface GateTally {
274
+ /** Distinct human approvers of this plan who count toward the quorum, one record each (their newest), oldest first. */
275
+ counted: GateResolutionRecord[];
276
+ /** The quorum's count, 1 when the gate declares none. */
277
+ need: number;
278
+ /** The newest approval whose recorded permit passes the gate on its own. Only in `enforce` mode, and only under the gate's current policy version. */
279
+ permit?: GateResolutionRecord;
280
+ /** The newest approval of this gate for a different plan, when the gate binds one. */
281
+ mismatched?: GateResolutionRecord;
282
+ }
283
+
284
+ /**
285
+ * Tally the approvals that answer a gate with an `approval` block (#2508).
286
+ *
287
+ * An approval counts only if it is newer than `sinceIso` (the standing pending
288
+ * fact) and, on a plan-bound gate, recorded for `planDigest`. So a changed
289
+ * plan invalidates every approval collected for the old one, the rule #2300
290
+ * set for a single approval.
291
+ *
292
+ * Of those, only a human's counts toward the quorum, once per `resolvedBy`,
293
+ * and only with one of the quorum's roles when it names any. An agent counts
294
+ * only through a recorded `allow` in `enforce` mode, evaluated under the
295
+ * policy version the gate declares now. A `log-only` decision never changes
296
+ * the outcome, and a `deny` never removes a human's approval: a policy can add
297
+ * a way through the gate but cannot take one away.
298
+ */
299
+ export function tallyGateApprovals(
300
+ records: GateResolutionRecord[],
301
+ gate: string,
302
+ sinceIso: string,
303
+ planDigest: string | undefined,
304
+ approval: ResolvedGateApproval,
305
+ ): GateTally {
306
+ const since = new Date(sinceIso).getTime();
307
+ const at = (r: GateResolutionRecord) => new Date(r.timestamp).getTime();
308
+ const roles = approval.quorum?.roles;
309
+
310
+ const byActor = new Map<string, GateResolutionRecord>();
311
+ let permit: GateResolutionRecord | undefined;
312
+ let mismatched: GateResolutionRecord | undefined;
313
+ for (const r of records) {
314
+ if (r.gate !== gate || at(r) < since) continue;
315
+ if (planDigest !== undefined && r.planDigest !== planDigest) {
316
+ if (!mismatched || at(r) >= at(mismatched)) mismatched = r;
317
+ continue;
318
+ }
319
+
320
+ const decision = r.policyDecision;
321
+ if (
322
+ approval.mode === "enforce" && approval.policy && decision?.decision === "allow" &&
323
+ decision.version === approval.policy.version && (!permit || at(r) >= at(permit))
324
+ ) {
325
+ permit = r;
326
+ }
327
+
328
+ const approver = approverOf(r);
329
+ if (approver.kind !== "human") continue;
330
+ if (roles && !(approver.roles ?? []).some((role) => roles.includes(role))) continue;
331
+ const prior = byActor.get(r.resolvedBy);
332
+ if (!prior || at(r) >= at(prior)) byActor.set(r.resolvedBy, r);
333
+ }
334
+
335
+ const counted = [...byActor.values()].sort((a, b) => at(a) - at(b));
336
+ return {
337
+ counted,
338
+ need: approval.quorum?.count ?? 1,
339
+ ...(permit ? { permit } : {}),
340
+ ...(mismatched ? { mismatched } : {}),
341
+ };
342
+ }
343
+
344
+ /** Whether two resolved approval blocks are the same, so a standing pending fact still describes this gate. */
345
+ function sameApproval(a: ResolvedGateApproval | undefined, b: ResolvedGateApproval | undefined): boolean {
346
+ return JSON.stringify(a ?? null, sortedJsonReplacer) === JSON.stringify(b ?? null, sortedJsonReplacer);
347
+ }
348
+
235
349
  /** The beginning of time — the anchor for a gate that has never been recorded pending, so any resolution for it counts. */
236
350
  const EPOCH = new Date(0).toISOString();
237
351
 
@@ -263,13 +377,30 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
263
377
  const { resolutions, pending } = await port.read(input.op);
264
378
 
265
379
  const standing = latestPendingGate(pending, input.gate);
266
- const { resolution, mismatched } = latestResolutionForPlan(
267
- resolutions,
268
- input.gate,
269
- standing?.timestamp ?? EPOCH,
270
- input.planDigest,
271
- );
272
- if (resolution) return { satisfied: true, resolution };
380
+ const since = standing?.timestamp ?? EPOCH;
381
+
382
+ let mismatched: GateResolutionRecord | undefined;
383
+ let quorum: GateQuorumProgress | undefined;
384
+ if (input.approval) {
385
+ const tally = tallyGateApprovals(resolutions, input.gate, since, input.planDigest, input.approval);
386
+ if (tally.permit) {
387
+ return { satisfied: true, resolution: tally.permit, via: "policy", approvals: tally.counted };
388
+ }
389
+ if (tally.counted.length >= tally.need) {
390
+ return {
391
+ satisfied: true,
392
+ resolution: tally.counted[tally.counted.length - 1]!,
393
+ via: "quorum",
394
+ approvals: tally.counted,
395
+ };
396
+ }
397
+ mismatched = tally.mismatched;
398
+ quorum = { approvers: tally.counted.map((r) => r.resolvedBy), need: tally.need };
399
+ } else {
400
+ const found = latestResolutionForPlan(resolutions, input.gate, since, input.planDigest);
401
+ if (found.resolution) return { satisfied: true, resolution: found.resolution };
402
+ mismatched = found.mismatched;
403
+ }
273
404
 
274
405
  // `input.planDigest` is defined whenever `mismatched` is — `latestResolutionForPlan`
275
406
  // returns a mismatch only on the plan-bound path.
@@ -281,14 +412,20 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
281
412
  timestamp: mismatched.timestamp,
282
413
  }
283
414
  : undefined;
284
- const asMismatch = mismatch ? { mismatch } : {};
415
+ const asMismatch = { ...(mismatch ? { mismatch } : {}), ...(quorum ? { quorum } : {}) };
285
416
 
286
417
  // A standing fact only stands for the plan it was recorded against. When
287
418
  // the plan has moved, re-recording is what gives `chant approve` (which
288
419
  // defaults to the newest pending fact's digest) the current plan to
289
420
  // approve; leaving the old fact standing would make the common path
290
421
  // approve a plan that is no longer the one being run.
291
- if (standing && !isPendingGateExpired(standing, now) && standing.planDigest === input.planDigest) {
422
+ // The same holds for the approval block (#2508): a pending fact recorded
423
+ // under another policy version or another context would have `chant
424
+ // approve` evaluate the policy against something this run no longer has.
425
+ if (
426
+ standing && !isPendingGateExpired(standing, now) && standing.planDigest === input.planDigest &&
427
+ sameApproval(standing.approval, input.approval)
428
+ ) {
292
429
  return { satisfied: false, pending: standing, recorded: false, ...asMismatch };
293
430
  }
294
431
 
@@ -304,6 +441,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
304
441
  ...(input.runId ? { runId: input.runId } : {}),
305
442
  ...(url ? { url } : {}),
306
443
  ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
444
+ ...(input.approval ? { approval: input.approval } : {}),
307
445
  });
308
446
  return {
309
447
  satisfied: false,
package/src/op/index.ts CHANGED
@@ -53,7 +53,15 @@ export { NonRetryableActivityError, nonRetryableFailure } from "./activity-failu
53
53
  export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
54
54
  export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
55
55
  export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch } from "./gate";
56
- export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch } from "./gate";
56
+ export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch, GateQuorumProgress, GateTally } from "./gate";
57
+ export { tallyGateApprovals, approverOf } from "./gate";
58
+ export {
59
+ gateApprovalProblems, gatePolicyRequest, gatePolicyVersion, isGatePolicyRef, loadGatePolicyEvaluator, GATE_APPROVAL_MODES,
60
+ } from "./gate-approval";
61
+ export type {
62
+ GateApproval, GateApprovalMode, GateApprover, GateContextValue, GatePolicyAnswer, GatePolicyDecision,
63
+ GatePolicyEvaluator, GatePolicyRef, GatePolicyRequest, GateQuorum, ResolvedGateApproval,
64
+ } from "./gate-approval";
57
65
  export {
58
66
  computePlanDigest, isPlanDigest, describePlanDigest, PLAN_DIGEST_ALGORITHM,
59
67
  } from "../lifecycle/plan-digest";
@@ -835,3 +835,69 @@ describe("runOpLocally — a gate approves a plan, not the next run (#2300)", ()
835
835
  expect(second.status).toBe("ok");
836
836
  });
837
837
  });
838
+
839
+ /**
840
+ * #2508: a gate with an approval block. The run resolves the block's context
841
+ * from the Plan phase, the pending fact carries it for `chant approve`, and the
842
+ * gate passes only once the quorum is met.
843
+ */
844
+ describe("runOpLocally — a gate with a quorum and a policy context (#2508)", () => {
845
+ const NOW = "2026-09-20T12:00:00.000Z";
846
+
847
+ test("records the resolved context on the pending fact, then passes on the second approver", async () => {
848
+ const resolutions: GateResolutionRecord[] = [];
849
+ const pending: PendingGateRecord[] = [];
850
+ const gates: GateLedgerPort = {
851
+ async read() {
852
+ return { resolutions: [...resolutions], pending: [...pending] };
853
+ },
854
+ async appendPending(input) {
855
+ const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
856
+ pending.push(record);
857
+ return { record, pushed: true };
858
+ },
859
+ };
860
+ const applied: string[] = [];
861
+ const activities = new Map<string, ActivityFn>([
862
+ ["assess", async () => ({ risk: "low", paths: ["src/a.ts"] })],
863
+ ["ship", async () => { applied.push("ship"); return {}; }],
864
+ ]);
865
+ const config = op({
866
+ name: "release",
867
+ phases: [
868
+ { name: "Plan", steps: [{ kind: "activity", fn: "assess", id: "assess", args: {} }] },
869
+ {
870
+ name: "Gate",
871
+ steps: [{
872
+ kind: "gate",
873
+ gate: "ship",
874
+ approval: {
875
+ quorum: { count: 2 },
876
+ context: { risk: stepOutput("assess", "risk"), paths: stepOutput("assess", "paths"), missing: stepOutput("assess", "nope") },
877
+ },
878
+ }],
879
+ },
880
+ { name: "Apply", steps: [{ kind: "activity", fn: "ship", args: {} }] },
881
+ ],
882
+ });
883
+ const approve = (resolvedBy: string, at: string) =>
884
+ resolutions.push({ version: 1, op: "release", gate: "ship", resolvedBy, timestamp: at, approver: { kind: "human" } });
885
+
886
+ const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW });
887
+ expect(first.status).toBe("gated");
888
+ expect(first.gate?.approval).toEqual({ quorum: { count: 2 }, mode: "log-only", context: { risk: "low", paths: ["src/a.ts"] } });
889
+
890
+ approve("alex", "2026-09-20T12:05:00.000Z");
891
+ const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-09-20T12:06:00.000Z" });
892
+ expect(second.status).toBe("gated");
893
+ expect(pending).toHaveLength(1);
894
+
895
+ approve("sam", "2026-09-20T12:07:00.000Z");
896
+ const third = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-09-20T12:08:00.000Z" });
897
+ expect(third.status).toBe("ok");
898
+ expect(applied).toEqual(["ship"]);
899
+ expect(third.records.find((r) => r.fn === "gate:ship")?.approval).toMatchObject({
900
+ resolvedBy: "sam", via: "quorum", approvers: ["alex", "sam"],
901
+ });
902
+ });
903
+ });
@@ -27,6 +27,7 @@ import { isStepOutputRef } from "./step-output-ref";
27
27
  import { parseDuration } from "./duration";
28
28
  import { describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
29
29
  import { gateName } from "./gate-name";
30
+ import type { ResolvedGateApproval } from "./gate-approval";
30
31
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
31
32
  import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
32
33
  import type { OpRunRecord } from "./runtime";
@@ -57,7 +58,16 @@ export interface StepRecord {
57
58
  outcomes?: Array<{ name: string; value: unknown }>;
58
59
  error?: string;
59
60
  /** Set on a `gate` step that passed (#2119): who resolved it, when, and at what address. */
60
- approval?: { gate: string; resolvedBy: string; timestamp: string; url?: string };
61
+ approval?: {
62
+ gate: string;
63
+ resolvedBy: string;
64
+ timestamp: string;
65
+ url?: string;
66
+ /** On a gate with an `approval` block (#2508): whether a quorum or a policy permit passed it. */
67
+ via?: "quorum" | "policy";
68
+ /** On a gate with a quorum (#2508): every approver who counted toward it. */
69
+ approvers?: string[];
70
+ };
61
71
  /**
62
72
  * Why a step declined to proceed on something that is not a failure
63
73
  * (#2300): a gate holding a standing approval for a *different* plan. Names
@@ -394,6 +404,35 @@ function gateFn(step: GateStep): string {
394
404
  return `gate:${gateName(step)}`;
395
405
  }
396
406
 
407
+ /**
408
+ * A gate's `approval` block as this run resolves it (#2508): each context
409
+ * value that is a step-output reference is replaced by what that step
410
+ * returned, the same walk `plan` goes through. A reference that resolves to
411
+ * nothing is dropped rather than failing the run, so a policy reading it sees
412
+ * the attribute as absent.
413
+ */
414
+ function resolveGateApproval(
415
+ step: GateStep,
416
+ resultsById: ReadonlyMap<string, unknown>,
417
+ ): ResolvedGateApproval | undefined {
418
+ const authored = step.approval;
419
+ if (!authored) return undefined;
420
+ let context: Record<string, unknown> | undefined;
421
+ if (authored.context) {
422
+ context = {};
423
+ for (const [key, value] of Object.entries(authored.context)) {
424
+ const resolved = resolveStepOutputRefs(value, resultsById);
425
+ if (resolved !== undefined && resolved !== null) context[key] = resolved;
426
+ }
427
+ }
428
+ return {
429
+ ...(authored.quorum ? { quorum: authored.quorum } : {}),
430
+ ...(authored.policy ? { policy: authored.policy } : {}),
431
+ mode: authored.mode ?? "log-only",
432
+ ...(context ? { context } : {}),
433
+ };
434
+ }
435
+
397
436
  /**
398
437
  * Decide one gate against the ledger. A resolution newer than the gate's
399
438
  * newest pending fact passes it, and the approver lands on the step record; a
@@ -414,6 +453,7 @@ async function runGateStep(
414
453
  // a run over a missing digest.
415
454
  const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
416
455
  const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
456
+ const approval = resolveGateApproval(step, resultsById);
417
457
  let check: GateCheck;
418
458
  try {
419
459
  check = await evaluateGate(gates.port, {
@@ -423,6 +463,7 @@ async function runGateStep(
423
463
  ...(step.timeout ? { timeout: step.timeout } : {}),
424
464
  ...(gates.runId ? { runId: gates.runId } : {}),
425
465
  ...(planDigest !== undefined ? { planDigest } : {}),
466
+ ...(approval ? { approval } : {}),
426
467
  ...(gates.now ? { now: gates.now } : {}),
427
468
  });
428
469
  } catch (err) {
@@ -460,6 +501,8 @@ async function runGateStep(
460
501
  resolvedBy: resolution.resolvedBy,
461
502
  timestamp: resolution.timestamp,
462
503
  ...(resolution.url ? { url: resolution.url } : {}),
504
+ ...(check.via ? { via: check.via } : {}),
505
+ ...(check.approvals ? { approvers: check.approvals.map((r) => r.resolvedBy) } : {}),
463
506
  },
464
507
  },
465
508
  };
package/src/op/op-ir.ts CHANGED
@@ -144,6 +144,8 @@ export interface OpIRGateStep {
144
144
  * Absent on a gate that binds no plan.
145
145
  */
146
146
  plan?: GateStep["plan"];
147
+ /** Quorum, roles and policy (#2508), carried as authored. Absent on a gate that passes on one approval. */
148
+ approval?: GateStep["approval"];
147
149
  }
148
150
 
149
151
  export interface OpIREffectStep {
@@ -240,6 +242,7 @@ function irGateStep(step: GateStep): OpIRGateStep {
240
242
  timeout: step.timeout ?? "48h",
241
243
  ...(step.description ? { description: step.description } : {}),
242
244
  ...(step.plan !== undefined ? { plan: step.plan } : {}),
245
+ ...(step.approval !== undefined ? { approval: step.approval } : {}),
243
246
  };
244
247
  }
245
248
 
@@ -389,6 +392,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
389
392
  timeout: step.timeout,
390
393
  ...(step.description ? { description: step.description } : {}),
391
394
  ...(step.plan !== undefined ? { plan: step.plan } : {}),
395
+ ...(step.approval !== undefined ? { approval: step.approval } : {}),
392
396
  };
393
397
  }
394
398
  return {
package/src/op/types.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import type { EffectReceiptRef } from "./receipt-store";
9
9
  import type { ActivityProfileName } from "./activity-profiles";
10
10
  import type { StepOutputRef } from "./step-output-ref";
11
+ import type { GateApproval } from "./gate-approval";
11
12
 
12
13
  export interface OpConfig {
13
14
  /** Kebab-case identifier. Names the Op's output directory (`dist/ops/<name>/`), and is the name `chant run <name>` and another Op's `depends` refer to. */
@@ -196,6 +197,12 @@ export interface GateStepBase {
196
197
  * phase publishes no digest is not thereby unrunnable.
197
198
  */
198
199
  plan?: string | StepOutputRef;
200
+ /**
201
+ * Quorum, roles and a policy for who may pass this gate (#2508). Absent,
202
+ * the gate passes on one approval, as every gate did before. See
203
+ * `./gate-approval.ts`.
204
+ */
205
+ approval?: GateApproval;
199
206
  }
200
207
 
201
208
  /**