@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.
Files changed (195) hide show
  1. package/dist/audit/core.d.ts.map +1 -1
  2. package/dist/audit/discover.d.ts +38 -0
  3. package/dist/audit/discover.d.ts.map +1 -1
  4. package/dist/audit/terraform-state.d.ts +14 -0
  5. package/dist/audit/terraform-state.d.ts.map +1 -1
  6. package/dist/cli/command-group.d.ts +6 -0
  7. package/dist/cli/command-group.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +27 -2
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/dist/cli/commands/import-agents.d.ts.map +1 -1
  13. package/dist/cli/commands/init.d.ts +6 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/onboard.d.ts.map +1 -1
  17. package/dist/cli/commands/update.d.ts.map +1 -1
  18. package/dist/cli/conflict-check.d.ts +6 -0
  19. package/dist/cli/conflict-check.d.ts.map +1 -1
  20. package/dist/cli/handlers/dev.d.ts.map +1 -1
  21. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  22. package/dist/cli/handlers/misc.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +17 -0
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/promote.d.ts +17 -0
  26. package/dist/cli/handlers/promote.d.ts.map +1 -0
  27. package/dist/cli/handlers/run-generate.d.ts +39 -0
  28. package/dist/cli/handlers/run-generate.d.ts.map +1 -0
  29. package/dist/cli/handlers/run.d.ts.map +1 -1
  30. package/dist/cli/main.d.ts.map +1 -1
  31. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  32. package/dist/cli/plugins.d.ts +9 -0
  33. package/dist/cli/plugins.d.ts.map +1 -1
  34. package/dist/cli/registry.d.ts +18 -1
  35. package/dist/cli/registry.d.ts.map +1 -1
  36. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  37. package/dist/components/cli-support.d.ts.map +1 -1
  38. package/dist/components/discover.d.ts.map +1 -1
  39. package/dist/components/driver.d.ts.map +1 -1
  40. package/dist/components/fan-out-support.d.ts.map +1 -1
  41. package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
  42. package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
  43. package/dist/components/presets/ecs-fargate.d.ts +10 -4
  44. package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
  45. package/dist/components/promote.d.ts +211 -0
  46. package/dist/components/promote.d.ts.map +1 -0
  47. package/dist/components/unbound-gate-approval.d.ts +24 -0
  48. package/dist/components/unbound-gate-approval.d.ts.map +1 -0
  49. package/dist/config.d.ts +82 -5
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/discovery/convergence.d.ts +97 -0
  52. package/dist/discovery/convergence.d.ts.map +1 -0
  53. package/dist/discovery/files.d.ts +41 -2
  54. package/dist/discovery/files.d.ts.map +1 -1
  55. package/dist/discovery/fold-import.d.ts +15 -0
  56. package/dist/discovery/fold-import.d.ts.map +1 -1
  57. package/dist/graph-ir.d.ts +13 -0
  58. package/dist/graph-ir.d.ts.map +1 -1
  59. package/dist/lexicon-module.d.ts +65 -0
  60. package/dist/lexicon-module.d.ts.map +1 -0
  61. package/dist/lexicon.d.ts +24 -0
  62. package/dist/lexicon.d.ts.map +1 -1
  63. package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
  64. package/dist/lifecycle/gate-ledger.d.ts +22 -0
  65. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  66. package/dist/lifecycle/legacy-digest.d.ts +26 -0
  67. package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
  68. package/dist/lifecycle/release-ledger.d.ts +25 -0
  69. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  70. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
  71. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
  72. package/dist/lint/rules/op/index.d.ts +2 -1
  73. package/dist/lint/rules/op/index.d.ts.map +1 -1
  74. package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
  75. package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
  76. package/dist/op/activity-contract-registry.d.ts.map +1 -1
  77. package/dist/op/activity-registry.d.ts.map +1 -1
  78. package/dist/op/builders.d.ts +4 -0
  79. package/dist/op/builders.d.ts.map +1 -1
  80. package/dist/op/discover.d.ts.map +1 -1
  81. package/dist/op/gate-approval.d.ts +142 -0
  82. package/dist/op/gate-approval.d.ts.map +1 -0
  83. package/dist/op/gate.d.ts +54 -0
  84. package/dist/op/gate.d.ts.map +1 -1
  85. package/dist/op/generate-pipeline.d.ts.map +1 -1
  86. package/dist/op/index.d.ts +4 -1
  87. package/dist/op/index.d.ts.map +1 -1
  88. package/dist/op/local-executor.d.ts +4 -0
  89. package/dist/op/local-executor.d.ts.map +1 -1
  90. package/dist/op/op-ir.d.ts +2 -0
  91. package/dist/op/op-ir.d.ts.map +1 -1
  92. package/dist/op/runtimes/local.d.ts.map +1 -1
  93. package/dist/op/types.d.ts +7 -0
  94. package/dist/op/types.d.ts.map +1 -1
  95. package/dist/workspace/record-source.d.ts +22 -0
  96. package/dist/workspace/record-source.d.ts.map +1 -0
  97. package/dist/workspace/records-cli.d.ts +57 -0
  98. package/dist/workspace/records-cli.d.ts.map +1 -0
  99. package/dist/workspace/records.d.ts +118 -0
  100. package/dist/workspace/records.d.ts.map +1 -0
  101. package/package.json +3 -3
  102. package/src/audit/core.ts +13 -2
  103. package/src/audit/discover.ts +90 -12
  104. package/src/audit/terraform-state.test.ts +86 -1
  105. package/src/audit/terraform-state.ts +26 -0
  106. package/src/cli/command-group.ts +7 -0
  107. package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
  108. package/src/cli/commands/audit.ts +93 -19
  109. package/src/cli/commands/check-lexicon-examples.ts +2 -1
  110. package/src/cli/commands/doctor.ts +3 -0
  111. package/src/cli/commands/import-agents.ts +11 -4
  112. package/src/cli/commands/init.ts +57 -25
  113. package/src/cli/commands/lint.test.ts +58 -1
  114. package/src/cli/commands/lint.ts +48 -18
  115. package/src/cli/commands/onboard.ts +15 -0
  116. package/src/cli/commands/update.ts +5 -1
  117. package/src/cli/conflict-check.test.ts +18 -1
  118. package/src/cli/conflict-check.ts +13 -0
  119. package/src/cli/discovery-skip.test.ts +168 -0
  120. package/src/cli/handlers/build.test.ts +11 -0
  121. package/src/cli/handlers/build.ts +1 -1
  122. package/src/cli/handlers/dev.ts +3 -0
  123. package/src/cli/handlers/graph.test.ts +20 -0
  124. package/src/cli/handlers/graph.ts +4 -2
  125. package/src/cli/handlers/lifecycle.ts +2 -1
  126. package/src/cli/handlers/misc.ts +16 -0
  127. package/src/cli/handlers/operator.test.ts +85 -0
  128. package/src/cli/handlers/operator.ts +118 -2
  129. package/src/cli/handlers/promote.test.ts +261 -0
  130. package/src/cli/handlers/promote.ts +325 -0
  131. package/src/cli/handlers/run-generate.test.ts +160 -0
  132. package/src/cli/handlers/run-generate.ts +133 -0
  133. package/src/cli/handlers/run.ts +13 -1
  134. package/src/cli/main.test.ts +33 -1
  135. package/src/cli/main.ts +78 -16
  136. package/src/cli/mcp/op-tools.ts +2 -1
  137. package/src/cli/path-lexicon-messages.test.ts +152 -0
  138. package/src/cli/plugins.ts +44 -6
  139. package/src/cli/registry.ts +18 -1
  140. package/src/components/__fixtures__/alb-ecs-service.json +0 -8
  141. package/src/components/capability-plugin-loader.ts +3 -1
  142. package/src/components/cli-support.ts +3 -2
  143. package/src/components/discover.ts +26 -2
  144. package/src/components/driver.test.ts +12 -11
  145. package/src/components/driver.ts +2 -0
  146. package/src/components/fan-out-support.ts +2 -1
  147. package/src/components/pilots/README.md +1 -1
  148. package/src/components/pilots/alb-ecs.pilot.ts +7 -7
  149. package/src/components/presets/ecs-fargate.ts +10 -5
  150. package/src/components/promote.test.ts +371 -0
  151. package/src/components/promote.ts +537 -0
  152. package/src/components/unbound-gate-approval.test.ts +82 -0
  153. package/src/components/unbound-gate-approval.ts +51 -0
  154. package/src/config.test.ts +40 -0
  155. package/src/config.ts +128 -11
  156. package/src/discovery/convergence.test.ts +231 -0
  157. package/src/discovery/convergence.ts +399 -0
  158. package/src/discovery/files.test.ts +127 -1
  159. package/src/discovery/files.ts +97 -9
  160. package/src/discovery/fold-import.ts +83 -9
  161. package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
  162. package/src/graph-ir.ts +14 -0
  163. package/src/lexicon-module.test.ts +203 -0
  164. package/src/lexicon-module.ts +117 -0
  165. package/src/lexicon.ts +26 -0
  166. package/src/lifecycle/build-ledger-store.ts +11 -1
  167. package/src/lifecycle/gate-ledger.ts +22 -0
  168. package/src/lifecycle/legacy-digest.test.ts +114 -0
  169. package/src/lifecycle/legacy-digest.ts +62 -0
  170. package/src/lifecycle/release-ledger.ts +34 -0
  171. package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
  172. package/src/lint/rules/op/index.ts +4 -2
  173. package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
  174. package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
  175. package/src/op/activity-contract-registry.ts +18 -4
  176. package/src/op/activity-registry.ts +26 -0
  177. package/src/op/builders.ts +10 -1
  178. package/src/op/discover-convergence.test.ts +70 -0
  179. package/src/op/discover.ts +4 -0
  180. package/src/op/gate-approval.test.ts +258 -0
  181. package/src/op/gate-approval.ts +260 -0
  182. package/src/op/gate.ts +148 -10
  183. package/src/op/generate-pipeline.ts +2 -1
  184. package/src/op/index.ts +9 -1
  185. package/src/op/local-executor.test.ts +66 -0
  186. package/src/op/local-executor.ts +44 -1
  187. package/src/op/op-ir.ts +4 -0
  188. package/src/op/runtimes/local.ts +2 -1
  189. package/src/op/types.ts +7 -0
  190. package/src/workspace/record-source.ts +117 -0
  191. package/src/workspace/records-cli.ts +126 -0
  192. package/src/workspace/records-contract.test.ts +88 -0
  193. package/src/workspace/records.schema.json +120 -0
  194. package/src/workspace/records.test.ts +243 -0
  195. 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
- | { 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,
@@ -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
+ });
@@ -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
  };