@intentius/chant 0.78.0 → 0.80.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/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +17 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +18 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.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/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.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/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +4 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.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/generate-pipeline.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/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.test.ts +85 -0
- package/src/cli/handlers/operator.ts +118 -2
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +78 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +18 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/gate-ledger.ts +22 -0
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- 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/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/builders.ts +10 -1
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.test.ts +258 -0
- package/src/op/gate-approval.ts +260 -0
- package/src/op/gate.ts +148 -10
- package/src/op/generate-pipeline.ts +2 -1
- 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/runtimes/local.ts +2 -1
- package/src/op/types.ts +7 -0
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate approval policy as data (#2508).
|
|
3
|
+
*
|
|
4
|
+
* A gate used to pass on one recorded approval. This module lets a gate say
|
|
5
|
+
* more than that, as data that survives into the build output:
|
|
6
|
+
*
|
|
7
|
+
* - `quorum`: how many distinct human approvers the gate needs, and
|
|
8
|
+
* optionally which roles count toward it.
|
|
9
|
+
* - `policy`: a policy set a lexicon rendered (the cedar lexicon's
|
|
10
|
+
* `gatePolicy()`), evaluated for each approval as a `PassGate` request.
|
|
11
|
+
* - `mode`: `"log-only"` records the policy's decision next to the approval
|
|
12
|
+
* and lets only the quorum bind. `"enforce"` lets a permit pass the gate on
|
|
13
|
+
* its own, which is how an agent principal passes a low-risk plan.
|
|
14
|
+
* - `context`: plan attributes handed to the policy (risk labels, changed
|
|
15
|
+
* paths), resolved at run time like a gate's `plan`.
|
|
16
|
+
*
|
|
17
|
+
* Core does not know Cedar. A {@link GatePolicyRef} names the lexicon that
|
|
18
|
+
* made it, and {@link loadGatePolicyEvaluator} imports that lexicon's
|
|
19
|
+
* `gate-policy` module by the same `@intentius/chant-lexicon-<name>/...`
|
|
20
|
+
* convention the activity registry uses (`./activity-registry.ts`).
|
|
21
|
+
*
|
|
22
|
+
* The policy is evaluated when an approval is recorded (`chant approve`),
|
|
23
|
+
* because that is the moment the approver is known. The decision is written
|
|
24
|
+
* on the resolution with the policy's version, and a run counts a recorded
|
|
25
|
+
* permit only while the gate still declares that same version. Editing the
|
|
26
|
+
* policy invalidates every permit recorded under the old one, the way a new
|
|
27
|
+
* plan invalidates every approval recorded for the old plan (#2300).
|
|
28
|
+
*
|
|
29
|
+
* The trust boundary is unchanged from `../lifecycle/gate-ledger.ts`: a
|
|
30
|
+
* resolution is a local fact, not a signed one, and roles on it are what the
|
|
31
|
+
* approver claimed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { lexiconModulePath } from "../lexicon-module";
|
|
36
|
+
import { isStepOutputRef, type StepOutputRef } from "./step-output-ref";
|
|
37
|
+
|
|
38
|
+
/** What a policy's decision does to the gate. */
|
|
39
|
+
export type GateApprovalMode = "log-only" | "enforce";
|
|
40
|
+
|
|
41
|
+
export const GATE_APPROVAL_MODES: readonly GateApprovalMode[] = ["log-only", "enforce"];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A policy set a lexicon rendered for a gate — plain data, so it lands in the
|
|
45
|
+
* Op's build output unchanged. Build one with the owning lexicon's helper
|
|
46
|
+
* (the cedar lexicon's `gatePolicy()`) rather than by hand.
|
|
47
|
+
*/
|
|
48
|
+
export interface GatePolicyRef {
|
|
49
|
+
kind: "gate-policy";
|
|
50
|
+
/** The lexicon whose `gate-policy` module evaluates {@link GatePolicyRef.text}. */
|
|
51
|
+
lexicon: string;
|
|
52
|
+
/** A name for the policy set, carried into every recorded decision. */
|
|
53
|
+
name: string;
|
|
54
|
+
/** Content digest of {@link GatePolicyRef.text}. A recorded permit counts only under the version it was evaluated against. */
|
|
55
|
+
version: string;
|
|
56
|
+
/** The policy set, in the lexicon's own language. */
|
|
57
|
+
text: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How many distinct human approvers a gate needs, and which roles count. */
|
|
61
|
+
export interface GateQuorum {
|
|
62
|
+
/** At least 1. */
|
|
63
|
+
count: number;
|
|
64
|
+
/** When set, only an approver holding one of these roles counts toward {@link GateQuorum.count}. */
|
|
65
|
+
roles?: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A value a gate hands its policy as context. */
|
|
69
|
+
export type GateContextValue = string | number | boolean | string[] | StepOutputRef;
|
|
70
|
+
|
|
71
|
+
/** The `approval` block on a `gate` step. */
|
|
72
|
+
export interface GateApproval {
|
|
73
|
+
quorum?: GateQuorum;
|
|
74
|
+
policy?: GatePolicyRef;
|
|
75
|
+
/** Default `"log-only"`, so a new policy is observed against real gate traffic before it binds. */
|
|
76
|
+
mode?: GateApprovalMode;
|
|
77
|
+
/** Plan attributes handed to the policy as Cedar `context`. A {@link StepOutputRef} is resolved at run time. */
|
|
78
|
+
context?: Record<string, GateContextValue>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* {@link GateApproval} as a run resolved it: context references replaced with
|
|
83
|
+
* the values they pointed at, mode defaulted. This is what a pending fact
|
|
84
|
+
* carries, so `chant approve` evaluates the policy against the plan the run
|
|
85
|
+
* actually produced.
|
|
86
|
+
*/
|
|
87
|
+
export interface ResolvedGateApproval {
|
|
88
|
+
quorum?: GateQuorum;
|
|
89
|
+
policy?: GatePolicyRef;
|
|
90
|
+
mode: GateApprovalMode;
|
|
91
|
+
context?: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Who recorded an approval. Absent on every resolution written before #2508, which reads as a human with no roles. */
|
|
95
|
+
export interface GateApprover {
|
|
96
|
+
kind: "human" | "agent";
|
|
97
|
+
roles?: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One `PassGate` question put to a policy. */
|
|
101
|
+
export interface GatePolicyRequest {
|
|
102
|
+
principal: { kind: GateApprover["kind"]; name: string; roles: string[] };
|
|
103
|
+
action: "PassGate";
|
|
104
|
+
resource: { op: string; gate: string };
|
|
105
|
+
/** The plan digest, when the gate binds one, plus the gate's declared context. */
|
|
106
|
+
context: Record<string, unknown>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What a lexicon's evaluator answers. */
|
|
110
|
+
export interface GatePolicyAnswer {
|
|
111
|
+
decision: "allow" | "deny";
|
|
112
|
+
/** Ids of the policies that determined the decision. */
|
|
113
|
+
determining: string[];
|
|
114
|
+
/** Per-policy evaluation errors. A policy that errors does not apply. */
|
|
115
|
+
errors: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A policy's decision, as recorded on a resolution. */
|
|
119
|
+
export interface GatePolicyDecision extends GatePolicyAnswer {
|
|
120
|
+
policy: string;
|
|
121
|
+
version: string;
|
|
122
|
+
/** The gate's mode when the decision was recorded. A run reads the gate's current mode, not this. */
|
|
123
|
+
mode: GateApprovalMode;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What a lexicon's `gate-policy` module exports. */
|
|
127
|
+
export interface GatePolicyEvaluator {
|
|
128
|
+
evaluateGatePolicy(policy: GatePolicyRef, request: GatePolicyRequest): Promise<GatePolicyAnswer> | GatePolicyAnswer;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The version of a gate policy: `sha256:` and the hex digest of its text. A lexicon's helper stamps it, and OPS015 checks it still matches. */
|
|
132
|
+
export function gatePolicyVersion(text: string): string {
|
|
133
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isGatePolicyRef(value: unknown): value is GatePolicyRef {
|
|
137
|
+
if (typeof value !== "object" || value === null) return false;
|
|
138
|
+
const v = value as Record<string, unknown>;
|
|
139
|
+
return (
|
|
140
|
+
v.kind === "gate-policy" &&
|
|
141
|
+
typeof v.lexicon === "string" && v.lexicon !== "" &&
|
|
142
|
+
typeof v.name === "string" && v.name !== "" &&
|
|
143
|
+
typeof v.version === "string" && v.version !== "" &&
|
|
144
|
+
typeof v.text === "string" && v.text.trim() !== ""
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Everything wrong with an `approval` block, one line each. The `gate()`
|
|
150
|
+
* builder throws on the first; OPS015 reports all of them over the build
|
|
151
|
+
* output, where a block could have been written without the builder.
|
|
152
|
+
*/
|
|
153
|
+
export function gateApprovalProblems(approval: unknown): string[] {
|
|
154
|
+
if (typeof approval !== "object" || approval === null || Array.isArray(approval)) {
|
|
155
|
+
return ["`approval` must be an object"];
|
|
156
|
+
}
|
|
157
|
+
const a = approval as Record<string, unknown>;
|
|
158
|
+
const problems: string[] = [];
|
|
159
|
+
|
|
160
|
+
if (a.quorum !== undefined) {
|
|
161
|
+
const q = a.quorum as Record<string, unknown> | null;
|
|
162
|
+
if (typeof q !== "object" || q === null) {
|
|
163
|
+
problems.push("`approval.quorum` must be an object");
|
|
164
|
+
} else {
|
|
165
|
+
if (typeof q.count !== "number" || !Number.isInteger(q.count) || q.count < 1) {
|
|
166
|
+
problems.push("`approval.quorum.count` must be an integer of at least 1");
|
|
167
|
+
}
|
|
168
|
+
if (q.roles !== undefined) {
|
|
169
|
+
if (!Array.isArray(q.roles) || q.roles.length === 0 || q.roles.some((r) => typeof r !== "string" || r === "")) {
|
|
170
|
+
problems.push("`approval.quorum.roles` must be a non-empty list of role names");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (a.policy !== undefined) {
|
|
177
|
+
if (!isGatePolicyRef(a.policy)) {
|
|
178
|
+
problems.push(
|
|
179
|
+
"`approval.policy` does not resolve to a gate policy set. Build it with the lexicon's helper, " +
|
|
180
|
+
"for example `gatePolicy(\"ship\", policies)` from @intentius/chant-lexicon-cedar",
|
|
181
|
+
);
|
|
182
|
+
} else if (a.policy.version !== gatePolicyVersion(a.policy.text)) {
|
|
183
|
+
problems.push(
|
|
184
|
+
`\`approval.policy\` "${a.policy.name}" has a version that is not the digest of its text, ` +
|
|
185
|
+
"so a recorded decision could not be traced to the rule that made it",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (a.mode !== undefined && !GATE_APPROVAL_MODES.includes(a.mode as GateApprovalMode)) {
|
|
191
|
+
problems.push(`\`approval.mode\` must be one of ${GATE_APPROVAL_MODES.map((m) => `"${m}"`).join(", ")}`);
|
|
192
|
+
}
|
|
193
|
+
if (a.mode === "enforce" && a.policy === undefined) {
|
|
194
|
+
problems.push("`approval.mode: \"enforce\"` needs an `approval.policy` to enforce");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (a.context !== undefined) {
|
|
198
|
+
if (typeof a.context !== "object" || a.context === null || Array.isArray(a.context)) {
|
|
199
|
+
problems.push("`approval.context` must be an object of plan attributes");
|
|
200
|
+
} else if (a.policy === undefined) {
|
|
201
|
+
problems.push("`approval.context` is only read by a policy, and this gate declares none");
|
|
202
|
+
} else {
|
|
203
|
+
for (const [key, value] of Object.entries(a.context)) {
|
|
204
|
+
if (!isContextValue(value)) {
|
|
205
|
+
problems.push(
|
|
206
|
+
`\`approval.context.${key}\` must be a string, number, boolean, list of strings, or a step output reference`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return problems;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isContextValue(value: unknown): boolean {
|
|
217
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
218
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
219
|
+
if (Array.isArray(value)) return value.every((v) => typeof v === "string");
|
|
220
|
+
return isStepOutputRef(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Import `lexicon`'s evaluator. Throws a message that names the package when it is not installed or exports none. */
|
|
224
|
+
export async function loadGatePolicyEvaluator(lexicon: string): Promise<GatePolicyEvaluator> {
|
|
225
|
+
// chant #2520 — a lexicon declared by module path exports its evaluator from that module.
|
|
226
|
+
const spec = lexiconModulePath(lexicon) ?? `@intentius/chant-lexicon-${lexicon}/gate-policy`;
|
|
227
|
+
let mod: Partial<GatePolicyEvaluator>;
|
|
228
|
+
try {
|
|
229
|
+
mod = (await import(spec)) as Partial<GatePolicyEvaluator>;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`the gate's policy is evaluated by ${spec}, which could not be loaded: ` +
|
|
233
|
+
(err instanceof Error ? err.message : String(err)),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (typeof mod.evaluateGatePolicy !== "function") {
|
|
237
|
+
throw new Error(`${spec} exports no evaluateGatePolicy function`);
|
|
238
|
+
}
|
|
239
|
+
return mod as GatePolicyEvaluator;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The request a policy is asked about one approval. */
|
|
243
|
+
export function gatePolicyRequest(input: {
|
|
244
|
+
op: string;
|
|
245
|
+
gate: string;
|
|
246
|
+
resolvedBy: string;
|
|
247
|
+
approver: GateApprover;
|
|
248
|
+
planDigest?: string;
|
|
249
|
+
context?: Record<string, unknown>;
|
|
250
|
+
}): GatePolicyRequest {
|
|
251
|
+
return {
|
|
252
|
+
principal: { kind: input.approver.kind, name: input.resolvedBy, roles: input.approver.roles ?? [] },
|
|
253
|
+
action: "PassGate",
|
|
254
|
+
resource: { op: input.op, gate: input.gate },
|
|
255
|
+
context: {
|
|
256
|
+
...(input.context ?? {}),
|
|
257
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
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,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { discoverOps, type DiscoveredOp } from "./discover";
|
|
20
|
+
import { lexiconModulePath } from "../lexicon-module";
|
|
20
21
|
import {
|
|
21
22
|
isLexiconPlugin,
|
|
22
23
|
type LexiconPlugin,
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
async function loadLexiconPlugin(name: string): Promise<LexiconPlugin | null> {
|
|
37
38
|
let mod: Record<string, unknown>;
|
|
38
39
|
try {
|
|
39
|
-
mod = (await import(`@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
|
|
40
|
+
mod = (await import(lexiconModulePath(name) ?? `@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
|
|
40
41
|
} catch {
|
|
41
42
|
return null;
|
|
42
43
|
}
|
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
|
};
|