@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.
- package/dist/cli/handlers/operator.d.ts +17 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +22 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
- package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
- package/dist/op/builders.d.ts +4 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +142 -0
- package/dist/op/gate-approval.d.ts.map +1 -0
- package/dist/op/gate.d.ts +54 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +4 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +85 -0
- package/src/cli/handlers/operator.ts +116 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/registry.ts +4 -0
- package/src/lifecycle/gate-ledger.ts +22 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
- package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
- package/src/op/builders.ts +10 -1
- package/src/op/gate-approval.test.ts +258 -0
- package/src/op/gate-approval.ts +258 -0
- package/src/op/gate.ts +148 -10
- package/src/op/index.ts +9 -1
- package/src/op/local-executor.test.ts +66 -0
- package/src/op/local-executor.ts +44 -1
- package/src/op/op-ir.ts +4 -0
- 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
|
-
| {
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/op/local-executor.ts
CHANGED
|
@@ -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?: {
|
|
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
|
/**
|