@intentius/chant 0.124.39 → 0.124.41

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 (337) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/component-endpoint.d.ts +20 -0
  11. package/dist/cli/component-endpoint.d.ts.map +1 -0
  12. package/dist/cli/component-endpoint.js +36 -0
  13. package/dist/cli/handlers/approve.d.ts +2 -1
  14. package/dist/cli/handlers/approve.d.ts.map +1 -1
  15. package/dist/cli/handlers/approve.js +20 -6
  16. package/dist/cli/handlers/build.d.ts.map +1 -1
  17. package/dist/cli/handlers/build.js +15 -1
  18. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  19. package/dist/cli/handlers/fan-out.js +26 -4
  20. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  21. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  22. package/dist/cli/handlers/gate-resume.js +15 -2
  23. package/dist/cli/handlers/lock.d.ts +8 -0
  24. package/dist/cli/handlers/lock.d.ts.map +1 -0
  25. package/dist/cli/handlers/lock.js +197 -0
  26. package/dist/cli/handlers/pr-env.d.ts +34 -0
  27. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  28. package/dist/cli/handlers/pr-env.js +436 -0
  29. package/dist/cli/handlers/pr.d.ts +74 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +514 -58
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +16 -2
  34. package/dist/cli/handlers/query.d.ts +6 -0
  35. package/dist/cli/handlers/query.d.ts.map +1 -0
  36. package/dist/cli/handlers/query.js +80 -0
  37. package/dist/cli/handlers/run.d.ts +4 -1
  38. package/dist/cli/handlers/run.d.ts.map +1 -1
  39. package/dist/cli/handlers/run.js +17 -6
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +41 -2
  42. package/dist/cli/parse-args.d.ts.map +1 -1
  43. package/dist/cli/parse-args.js +31 -2
  44. package/dist/cli/registry.d.ts +12 -2
  45. package/dist/cli/registry.d.ts.map +1 -1
  46. package/dist/components/auto-release.d.ts +35 -3
  47. package/dist/components/auto-release.d.ts.map +1 -1
  48. package/dist/components/auto-release.js +29 -2
  49. package/dist/components/cli-support.d.ts +2 -0
  50. package/dist/components/cli-support.d.ts.map +1 -1
  51. package/dist/components/cli-support.js +2 -0
  52. package/dist/components/deploy-units.d.ts.map +1 -1
  53. package/dist/components/deploy-units.js +5 -0
  54. package/dist/components/fan-out-run.d.ts +28 -0
  55. package/dist/components/fan-out-run.d.ts.map +1 -1
  56. package/dist/components/fan-out-run.js +27 -3
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +72 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +96 -5
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts.map +1 -1
  70. package/dist/components/pr-run.js +5 -3
  71. package/dist/components/promote.d.ts.map +1 -1
  72. package/dist/components/promote.js +39 -16
  73. package/dist/gate-decision.d.ts +15 -2
  74. package/dist/gate-decision.d.ts.map +1 -1
  75. package/dist/gate-decision.js +17 -2
  76. package/dist/gate-policy.d.ts +140 -0
  77. package/dist/gate-policy.d.ts.map +1 -0
  78. package/dist/gate-policy.js +242 -0
  79. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  80. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  86. package/dist/lifecycle/publish-declaration.js +4 -3
  87. package/dist/lifecycle/publish-npm.d.ts +2 -2
  88. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  89. package/dist/lifecycle/publish-npm.js +31 -8
  90. package/dist/lifecycle/query.d.ts +68 -0
  91. package/dist/lifecycle/query.d.ts.map +1 -0
  92. package/dist/lifecycle/query.js +246 -0
  93. package/dist/lifecycle/release-ledger.d.ts +31 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +19 -1
  96. package/dist/lifecycle/run-ledger.d.ts +13 -0
  97. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  98. package/dist/lifecycle/run-ledger.js +16 -0
  99. package/dist/lifecycle/status.d.ts +5 -1
  100. package/dist/lifecycle/status.d.ts.map +1 -1
  101. package/dist/lifecycle/status.js +10 -5
  102. package/dist/lifecycle/target-lock.d.ts +97 -0
  103. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  104. package/dist/lifecycle/target-lock.js +286 -0
  105. package/dist/live-endpoint.d.ts +18 -0
  106. package/dist/live-endpoint.d.ts.map +1 -1
  107. package/dist/live-endpoint.js +27 -0
  108. package/dist/op/activities/activity-contracts.d.ts +30 -0
  109. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  110. package/dist/op/activities/activity-contracts.js +26 -0
  111. package/dist/op/activities/index.d.ts +2 -0
  112. package/dist/op/activities/index.d.ts.map +1 -1
  113. package/dist/op/activities/index.js +4 -0
  114. package/dist/op/activities/review-agent.d.ts +137 -0
  115. package/dist/op/activities/review-agent.d.ts.map +1 -0
  116. package/dist/op/activities/review-agent.js +266 -0
  117. package/dist/op/builders.d.ts +9 -0
  118. package/dist/op/builders.d.ts.map +1 -1
  119. package/dist/op/builders.js +11 -0
  120. package/dist/op/cron.d.ts +8 -0
  121. package/dist/op/cron.d.ts.map +1 -1
  122. package/dist/op/cron.js +51 -0
  123. package/dist/op/gate-approval.d.ts +20 -13
  124. package/dist/op/gate-approval.d.ts.map +1 -1
  125. package/dist/op/gate-approval.js +13 -18
  126. package/dist/op/gate-quorum.d.ts +131 -0
  127. package/dist/op/gate-quorum.d.ts.map +1 -0
  128. package/dist/op/gate-quorum.js +183 -0
  129. package/dist/op/gate-resume.d.ts +4 -2
  130. package/dist/op/gate-resume.d.ts.map +1 -1
  131. package/dist/op/gate-resume.js +5 -4
  132. package/dist/op/gate-review.js +1 -1
  133. package/dist/op/gate.d.ts +32 -4
  134. package/dist/op/gate.d.ts.map +1 -1
  135. package/dist/op/gate.js +40 -24
  136. package/dist/op/index.d.ts +6 -4
  137. package/dist/op/index.d.ts.map +1 -1
  138. package/dist/op/index.js +4 -3
  139. package/dist/op/local-executor.d.ts.map +1 -1
  140. package/dist/op/local-executor.js +9 -3
  141. package/dist/op/op-waves-run.d.ts +62 -1
  142. package/dist/op/op-waves-run.d.ts.map +1 -1
  143. package/dist/op/op-waves-run.js +190 -11
  144. package/dist/op/op-waves.d.ts +55 -3
  145. package/dist/op/op-waves.d.ts.map +1 -1
  146. package/dist/op/op-waves.js +63 -8
  147. package/dist/op/schedule-overdue.d.ts +57 -0
  148. package/dist/op/schedule-overdue.d.ts.map +1 -0
  149. package/dist/op/schedule-overdue.js +138 -0
  150. package/dist/override-gate.d.ts +2 -2
  151. package/dist/override-gate.d.ts.map +1 -1
  152. package/dist/override-gate.js +1 -1
  153. package/dist/pr-forge.d.ts +36 -0
  154. package/dist/pr-forge.d.ts.map +1 -1
  155. package/dist/pr-forge.js +82 -0
  156. package/dist/pr-loop.d.ts +90 -1
  157. package/dist/pr-loop.d.ts.map +1 -1
  158. package/dist/pr-loop.js +77 -3
  159. package/dist/pr-post.d.ts +72 -0
  160. package/dist/pr-post.d.ts.map +1 -0
  161. package/dist/pr-post.js +134 -0
  162. package/dist/wave-blocked.d.ts +79 -0
  163. package/dist/wave-blocked.d.ts.map +1 -0
  164. package/dist/wave-blocked.js +245 -0
  165. package/dist/wave-engine.d.ts +6 -0
  166. package/dist/wave-engine.d.ts.map +1 -1
  167. package/dist/wave-engine.js +2 -0
  168. package/dist/wave-runtime.mjs +239 -239
  169. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  170. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  171. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-locks.d.ts +12 -0
  179. package/dist/workspace/ci-locks.d.ts.map +1 -0
  180. package/dist/workspace/ci-locks.js +6 -0
  181. package/dist/workspace/ci-run.d.ts +24 -1
  182. package/dist/workspace/ci-run.d.ts.map +1 -1
  183. package/dist/workspace/ci-run.js +85 -6
  184. package/dist/workspace/declaration.d.ts +63 -0
  185. package/dist/workspace/declaration.d.ts.map +1 -1
  186. package/dist/workspace/declaration.js +72 -1
  187. package/dist/workspace/declaration.schema.json +147 -0
  188. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  189. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  190. package/dist/workspace/identity-gate-rule.js +1 -1
  191. package/dist/workspace/identity.d.ts +1 -1
  192. package/dist/workspace/identity.d.ts.map +1 -1
  193. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  194. package/dist/workspace/lineage-upgrade.js +8 -5
  195. package/dist/workspace/member-pipeline.d.ts +6 -0
  196. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  197. package/dist/workspace/member-pipeline.js +23 -0
  198. package/dist/workspace/pr-report.schema.json +73 -2
  199. package/dist/workspace/security.d.ts.map +1 -1
  200. package/dist/workspace/security.js +4 -0
  201. package/dist/workspace/status-gates.d.ts +3 -2
  202. package/dist/workspace/status-gates.d.ts.map +1 -1
  203. package/dist/workspace/status-gates.js +8 -7
  204. package/dist/workspace/status.d.ts +3 -1
  205. package/dist/workspace/status.d.ts.map +1 -1
  206. package/dist/workspace/status.js +7 -3
  207. package/dist/workspace/status.schema.json +1 -0
  208. package/dist/workspace/trust/seal.d.ts +2 -2
  209. package/dist/workspace/trust/seal.d.ts.map +1 -1
  210. package/dist/workspace/trust/seal.js +2 -2
  211. package/package.json +1 -1
  212. package/src/__fixtures__/oidc-emulator.ts +118 -0
  213. package/src/ci-handover.test.ts +209 -0
  214. package/src/ci-handover.ts +237 -0
  215. package/src/ci-identity.test.ts +201 -0
  216. package/src/ci-identity.ts +310 -0
  217. package/src/cli/component-endpoint.ts +43 -0
  218. package/src/cli/handlers/approve.ts +18 -5
  219. package/src/cli/handlers/build.ts +15 -1
  220. package/src/cli/handlers/fan-out.test.ts +10 -0
  221. package/src/cli/handlers/fan-out.ts +29 -4
  222. package/src/cli/handlers/gate-resume.ts +16 -2
  223. package/src/cli/handlers/lock.test.ts +98 -0
  224. package/src/cli/handlers/lock.ts +202 -0
  225. package/src/cli/handlers/operator.test.ts +3 -1
  226. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  227. package/src/cli/handlers/pr-env.ts +462 -0
  228. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  229. package/src/cli/handlers/pr.test.ts +184 -0
  230. package/src/cli/handlers/pr.ts +508 -64
  231. package/src/cli/handlers/promote.ts +25 -2
  232. package/src/cli/handlers/query.ts +78 -0
  233. package/src/cli/handlers/run.test.ts +2 -1
  234. package/src/cli/handlers/run.ts +16 -5
  235. package/src/cli/main.ts +41 -2
  236. package/src/cli/parse-args.ts +23 -2
  237. package/src/cli/registry.ts +12 -2
  238. package/src/components/auto-release-infra.test.ts +131 -0
  239. package/src/components/auto-release.test.ts +40 -1
  240. package/src/components/auto-release.ts +50 -4
  241. package/src/components/cli-support.ts +4 -0
  242. package/src/components/deploy-units.ts +5 -0
  243. package/src/components/fan-out-run.ts +39 -2
  244. package/src/components/fan-out-waves.test.ts +45 -1
  245. package/src/components/pr-environment.test.ts +249 -0
  246. package/src/components/pr-environment.ts +538 -0
  247. package/src/components/pr-hooks.test.ts +118 -0
  248. package/src/components/pr-hooks.ts +157 -0
  249. package/src/components/pr-pipeline.ts +130 -5
  250. package/src/components/pr-policy.test.ts +145 -0
  251. package/src/components/pr-policy.ts +211 -0
  252. package/src/components/pr-run.ts +5 -2
  253. package/src/components/promote.test.ts +39 -0
  254. package/src/components/promote.ts +38 -14
  255. package/src/gate-decision-bundle.test.ts +4 -3
  256. package/src/gate-decision.ts +39 -2
  257. package/src/gate-policy-bundle.test.ts +28 -0
  258. package/src/gate-policy.test.ts +162 -0
  259. package/src/gate-policy.ts +298 -0
  260. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  261. package/src/lifecycle/gate-checkpoint.ts +1 -1
  262. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  263. package/src/lifecycle/publish-declaration.ts +4 -3
  264. package/src/lifecycle/publish-npm.ts +33 -8
  265. package/src/lifecycle/publish-targets.test.ts +36 -3
  266. package/src/lifecycle/query.test.ts +91 -0
  267. package/src/lifecycle/query.ts +315 -0
  268. package/src/lifecycle/release-ledger.ts +42 -1
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/status.test.ts +19 -0
  271. package/src/lifecycle/status.ts +14 -6
  272. package/src/lifecycle/target-lock.test.ts +195 -0
  273. package/src/lifecycle/target-lock.ts +347 -0
  274. package/src/live-endpoint.ts +31 -0
  275. package/src/op/activities/activity-contracts.ts +31 -0
  276. package/src/op/activities/index.ts +6 -0
  277. package/src/op/activities/review-agent.test.ts +240 -0
  278. package/src/op/activities/review-agent.ts +355 -0
  279. package/src/op/builders.ts +13 -0
  280. package/src/op/cron.ts +44 -0
  281. package/src/op/gate-alias.test.ts +2 -2
  282. package/src/op/gate-approval.test.ts +30 -9
  283. package/src/op/gate-approval.ts +23 -26
  284. package/src/op/gate-quorum.test.ts +139 -0
  285. package/src/op/gate-quorum.ts +248 -0
  286. package/src/op/gate-resume.ts +6 -3
  287. package/src/op/gate-review.ts +1 -1
  288. package/src/op/gate.ts +64 -23
  289. package/src/op/index.ts +13 -1
  290. package/src/op/local-executor.ts +9 -2
  291. package/src/op/op-waves-hook-targets.test.ts +126 -0
  292. package/src/op/op-waves-policy-hold.test.ts +66 -0
  293. package/src/op/op-waves-quorum.test.ts +101 -0
  294. package/src/op/op-waves-run.ts +233 -11
  295. package/src/op/op-waves.ts +83 -10
  296. package/src/op/schedule-overdue.test.ts +220 -0
  297. package/src/op/schedule-overdue.ts +161 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +114 -0
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +146 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/wave-blocked-bundle.test.ts +25 -0
  309. package/src/wave-blocked.test.ts +146 -0
  310. package/src/wave-blocked.ts +271 -0
  311. package/src/wave-engine.ts +8 -0
  312. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  313. package/src/workspace/ci-branches.test.ts +62 -0
  314. package/src/workspace/ci-branches.ts +79 -0
  315. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  316. package/src/workspace/ci-cloud-identity.ts +23 -0
  317. package/src/workspace/ci-locks.ts +16 -0
  318. package/src/workspace/ci-run-index.test.ts +2 -1
  319. package/src/workspace/ci-run.test.ts +133 -8
  320. package/src/workspace/ci-run.ts +92 -7
  321. package/src/workspace/declaration.schema.json +147 -0
  322. package/src/workspace/declaration.test.ts +76 -0
  323. package/src/workspace/declaration.ts +134 -2
  324. package/src/workspace/identity-gate-rule.ts +1 -1
  325. package/src/workspace/identity.ts +1 -1
  326. package/src/workspace/lineage-upgrade.ts +11 -8
  327. package/src/workspace/member-pipeline.test.ts +29 -0
  328. package/src/workspace/member-pipeline.ts +30 -0
  329. package/src/workspace/pr-report.schema.json +73 -2
  330. package/src/workspace/security.ts +4 -0
  331. package/src/workspace/status-gates.ts +9 -6
  332. package/src/workspace/status.schema.json +1 -0
  333. package/src/workspace/status.ts +11 -4
  334. package/src/workspace/trust/gate-seal.test.ts +1 -1
  335. package/src/workspace/trust/seal.ts +2 -2
  336. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  337. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -14,7 +14,7 @@ import { runOpLocally } from "./local-executor";
14
14
  import { renderHuman } from "./local-output";
15
15
  import { stepOutput } from "./step-output-ref";
16
16
  import { parseGateLedger, type GateResolutionRecord, type PendingGateRecord } from "../lifecycle/gate-ledger";
17
- import { gateSealPayload, gateSealVersion } from "../workspace/trust/gate-seal";
17
+ import { gateSealPayload, gateSealVersion } from "../lifecycle/gate-seal";
18
18
  import { deriveGateAudit } from "../lifecycle/gate-audit";
19
19
  import type { AppliedGateRecord } from "../lifecycle/gate-applied";
20
20
  import type { ActivityFn } from "./activity-registry";
@@ -232,7 +232,7 @@ describe("old and new approvals add up toward one quorum", () => {
232
232
  });
233
233
  const check = await evaluateGate(port, input(quorum));
234
234
  expect(check.satisfied).toBe(false);
235
- expect(!check.satisfied && check.quorum).toEqual({ approvers: ["alice"], need: 2 });
235
+ expect(!check.satisfied && check.quorum).toMatchObject({ approvers: ["alice"], need: 2 });
236
236
  expect(port.resolved).toHaveLength(0);
237
237
  });
238
238
 
@@ -12,6 +12,7 @@ import { describeApprovalProgress } from "../cli/handlers/operator";
12
12
  import { gate } from "./builders";
13
13
  import { gateApprovalProblems, gateContextDigest, gatePolicyRequest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
14
14
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
15
+ import { rolesOf, type GateQuorumJudge } from "./gate-quorum";
15
16
 
16
17
  const PLAN_A = `sha256:${"a".repeat(64)}`;
17
18
  const PLAN_B = `sha256:${"b".repeat(64)}`;
@@ -33,9 +34,16 @@ function allow(mode: "log-only" | "enforce" = "enforce", context: Record<string,
33
34
  return { policy: "ship", version: POLICY.version, mode, contextDigest: gateContextDigest(context), decision: "allow", determining: ["ship-0"], errors: [] };
34
35
  }
35
36
 
36
- async function decide(resolutions: GateResolutionRecord[], approvalBlock: ResolvedGateApproval, planDigest = PLAN_A) {
37
+ /**
38
+ * #4290: roles come from .chant/trust.json at base, for a proven approver.
39
+ * The tests' judge proves everyone and grants these roles.
40
+ */
41
+ const ROLES: Record<string, string[]> = { maintainer: ["alex", "sam"], sre: ["sam"], viewer: ["pat"] };
42
+ const provenJudge: GateQuorumJudge = (r) => ({ proven: true, roles: rolesOf(r.resolvedBy, ROLES) });
43
+
44
+ async function decide(resolutions: GateResolutionRecord[], approvalBlock: ResolvedGateApproval, planDigest = PLAN_A, judge: GateQuorumJudge = provenJudge) {
37
45
  const port = memoryGateLedgerPort({ resolutions, pending: [{ ...PENDING, approval: approvalBlock, planDigest }] });
38
- const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest, approval: approvalBlock, now: NOW });
46
+ const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest, approval: approvalBlock, now: NOW, judge });
39
47
  return { check, port };
40
48
  }
41
49
 
@@ -49,7 +57,7 @@ describe("gate approval — quorum (#2508)", () => {
49
57
  );
50
58
  expect(check.satisfied).toBe(false);
51
59
  if (check.satisfied) return;
52
- expect(check.quorum).toEqual({ approvers: ["alex"], need: 2 });
60
+ expect(check.quorum).toMatchObject({ approvers: ["alex"], need: 2 });
53
61
  // The standing fact still describes this gate, so nothing new is appended.
54
62
  expect(check.recorded).toBe(false);
55
63
  expect(port.appended).toHaveLength(0);
@@ -107,10 +115,23 @@ describe("gate approval — quorum (#2508)", () => {
107
115
  if (check.satisfied) return;
108
116
  expect(check.mismatch?.approved).toBe(PLAN_A);
109
117
  expect(check.mismatch?.planned).toBe(PLAN_B);
110
- expect(check.quorum).toEqual({ approvers: [], need: 2 });
118
+ expect(check.quorum).toMatchObject({ approvers: [], need: 2 });
111
119
  expect(port.appended).toHaveLength(0);
112
120
  });
113
121
 
122
+ test("#4290: a role the approver claims never counts; only trust.json's grant to a proven approver does", async () => {
123
+ const claimed = [
124
+ approval({ resolvedBy: "pat", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
125
+ approval({ resolvedBy: "kim", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
126
+ ];
127
+ expect((await decide(claimed, twoMaintainers)).check.satisfied).toBe(false);
128
+ // alex and sam hold the role at base, and nothing proves them.
129
+ const unproven: GateQuorumJudge = (r) => ({ proven: false, roles: rolesOf(r.resolvedBy, ROLES) });
130
+ const granted = [approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z" }), approval({ resolvedBy: "sam", timestamp: "2026-09-01T02:00:00.000Z" })];
131
+ expect((await decide(granted, twoMaintainers, PLAN_A, unproven)).check.satisfied).toBe(false);
132
+ expect((await decide(granted, twoMaintainers)).check.satisfied).toBe(true);
133
+ });
134
+
114
135
  // #2547: a gate still pending, and an approval given, under the bare sha256: prefix
115
136
  // keep working once the run computes the same plan's digest as jcs1-sha256:.
116
137
  test("a pending gate and an approval recorded under the bare prefix match the jcs1 digest of the same plan", async () => {
@@ -153,7 +174,7 @@ describe("gate approval — policy decisions (#2508)", () => {
153
174
  );
154
175
  expect(check.satisfied).toBe(false);
155
176
  if (check.satisfied) return;
156
- expect(check.quorum).toEqual({ approvers: [], need: 1 });
177
+ expect(check.quorum).toMatchObject({ approvers: [], need: 1 });
157
178
  });
158
179
 
159
180
  test("log-only: a human's approval passes even when the policy recorded a deny", async () => {
@@ -339,7 +360,7 @@ describe("gate approval — a re-recorded pending fact keeps a partly filled quo
339
360
  expect(first.satisfied).toBe(false);
340
361
  if (first.satisfied) return;
341
362
  expect(first.recorded).toBe(true);
342
- expect(first.quorum).toEqual({ approvers: ["alex"], need: 2 });
363
+ expect(first.quorum).toMatchObject({ approvers: ["alex"], need: 2 });
343
364
 
344
365
  expect(after.satisfied).toBe(true);
345
366
  if (!after.satisfied) return;
@@ -356,7 +377,7 @@ describe("gate approval — a re-recorded pending fact keeps a partly filled quo
356
377
  const { after } = await switchThenApprove(twoLogOnly, twoEnforce, []);
357
378
  expect(after.satisfied).toBe(false);
358
379
  if (after.satisfied) return;
359
- expect(after.quorum).toEqual({ approvers: ["alex"], need: 2 });
380
+ expect(after.quorum).toMatchObject({ approvers: ["alex"], need: 2 });
360
381
  });
361
382
 
362
383
  test("a changed policy version starts the count again", async () => {
@@ -365,7 +386,7 @@ describe("gate approval — a re-recorded pending fact keeps a partly filled quo
365
386
  const { after } = await switchThenApprove(twoLogOnly, { ...twoLogOnly, policy: edited }, [sam]);
366
387
  expect(after.satisfied).toBe(false);
367
388
  if (after.satisfied) return;
368
- expect(after.quorum).toEqual({ approvers: ["sam"], need: 2 });
389
+ expect(after.quorum).toMatchObject({ approvers: ["sam"], need: 2 });
369
390
  });
370
391
 
371
392
  test("a fact for another plan in between starts the count again", async () => {
@@ -497,7 +518,7 @@ describe("gate approval — the request a policy is asked (#2508)", () => {
497
518
  describe("gate approval — the approval block is checked where it is written (#2508)", () => {
498
519
  test("a well-formed block passes, and lands on the step", () => {
499
520
  const step = gate("ship", { approval: { quorum: { count: 2, roles: ["maintainer"] }, policy: POLICY, mode: "enforce", context: { risk: "low" } } });
500
- expect(step.approval?.quorum?.count).toBe(2);
521
+ expect((step.approval?.quorum as { count: number } | undefined)?.count).toBe(2);
501
522
  expect(gateApprovalProblems(step.approval)).toEqual([]);
502
523
  });
503
524
 
@@ -5,7 +5,9 @@
5
5
  * more than that, as data that survives into the build output:
6
6
  *
7
7
  * - `quorum`: how many distinct human approvers the gate needs, and
8
- * optionally which roles count toward it.
8
+ * optionally which roles count toward it. Since #4290 it may be a list of
9
+ * rules, each scoped by `targets` globs over the run's environment
10
+ * (`./gate-quorum.ts`).
9
11
  * - `policy`: a policy set a lexicon rendered (the cedar lexicon's
10
12
  * `gatePolicy()`), evaluated for each approval as a `PassGate` request.
11
13
  * - `mode`: `"log-only"` records the policy's decision next to the approval
@@ -30,15 +32,20 @@
30
32
  * decision also records the digest of the context it was asked about, and a
31
33
  * permit counts only while the run resolves that same context (#3774).
32
34
  *
33
- * The trust boundary is unchanged from `../lifecycle/gate-ledger.ts`: a
34
- * resolution is a local fact, not a signed one, and roles on it are what the
35
- * approver claimed.
35
+ * A resolution is a local fact, not a signed one (`../lifecycle/gate-ledger.ts`).
36
+ * Roles on it are what the approver claimed, and since #4290 the quorum
37
+ * never reads them: a role counts only when `.chant/trust.json` at base
38
+ * grants it and a seal proves the approver (`./gate-quorum.ts`). A policy
39
+ * still sees the claimed roles in its request.
36
40
  */
37
41
 
38
42
  import { createHash } from "node:crypto";
39
43
  import { lexiconModulePath, importLexiconPackage } from "../lexicon-module";
40
44
  import { isStepOutputRef, type StepOutputRef } from "./step-output-ref";
41
45
  import { GATE_PLAN_CONTEXT_KEY } from "./gate-plan-context";
46
+ import { quorumProblems, type GateQuorumRule, type GateQuorumSpec } from "./gate-quorum";
47
+
48
+ export type { GateQuorumRule, GateQuorumSpec } from "./gate-quorum";
42
49
 
43
50
  /** What a policy's decision does to the gate. */
44
51
  export type GateApprovalMode = "log-only" | "enforce";
@@ -62,20 +69,20 @@ export interface GatePolicyRef {
62
69
  text: string;
63
70
  }
64
71
 
65
- /** How many distinct human approvers a gate needs, and which roles count. */
66
- export interface GateQuorum {
67
- /** At least 1. */
68
- count: number;
69
- /** When set, only an approver holding one of these roles counts toward {@link GateQuorum.count}. */
70
- roles?: string[];
71
- }
72
+ /**
73
+ * How many distinct human approvers a gate needs, and which roles count: one
74
+ * approval rule (#4290, `./gate-quorum.ts`). A role counts only when
75
+ * `.chant/trust.json` at base grants it to a proven approver.
76
+ */
77
+ export type GateQuorum = GateQuorumRule;
72
78
 
73
79
  /** A value a gate hands its policy as context. */
74
80
  export type GateContextValue = string | number | boolean | string[] | StepOutputRef;
75
81
 
76
82
  /** The `approval` block on a `gate` step. */
77
83
  export interface GateApproval {
78
- quorum?: GateQuorum;
84
+ /** One rule, or a list of rules scoped by `targets` over the run's environment (#4290). */
85
+ quorum?: GateQuorumSpec;
79
86
  policy?: GatePolicyRef;
80
87
  /** Default `"log-only"`, so a new policy is observed against real gate traffic before it binds. */
81
88
  mode?: GateApprovalMode;
@@ -96,7 +103,8 @@ export interface GateApproval {
96
103
  * actually produced.
97
104
  */
98
105
  export interface ResolvedGateApproval {
99
- quorum?: GateQuorum;
106
+ /** The rules this run's gate takes (#4290): a list, or one rule on a fact written before. */
107
+ quorum?: GateQuorumSpec;
100
108
  policy?: GatePolicyRef;
101
109
  mode: GateApprovalMode;
102
110
  context?: Record<string, unknown>;
@@ -197,19 +205,8 @@ export function gateApprovalProblems(approval: unknown): string[] {
197
205
  const problems: string[] = [];
198
206
 
199
207
  if (a.quorum !== undefined) {
200
- const q = a.quorum as Record<string, unknown> | null;
201
- if (typeof q !== "object" || q === null) {
202
- problems.push("`approval.quorum` must be an object");
203
- } else {
204
- if (typeof q.count !== "number" || !Number.isInteger(q.count) || q.count < 1) {
205
- problems.push("`approval.quorum.count` must be an integer of at least 1");
206
- }
207
- if (q.roles !== undefined) {
208
- if (!Array.isArray(q.roles) || q.roles.length === 0 || q.roles.some((r) => typeof r !== "string" || r === "")) {
209
- problems.push("`approval.quorum.roles` must be a non-empty list of role names");
210
- }
211
- }
212
- }
208
+ if (typeof a.quorum !== "object" || a.quorum === null) problems.push("`approval.quorum` must be an object or a list of rules");
209
+ else problems.push(...quorumProblems(a.quorum, "approval.quorum", { notAuthor: false }));
213
210
  }
214
211
 
215
212
  if (a.policy !== undefined) {
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ quorumProblems,
4
+ quorumRuleLine,
5
+ quorumRuleProgress,
6
+ quorumRulesFor,
7
+ rolesOf,
8
+ targetGlobMatch,
9
+ trustQuorumJudge,
10
+ type GateQuorumJudge,
11
+ type GateQuorumRule,
12
+ } from "./gate-quorum";
13
+ import { evaluateGate, memoryGateLedgerPort, tallyGateApprovals } from "./gate";
14
+ import { gateApprovalProblems } from "./gate-approval";
15
+ import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
16
+ import { gatesInLedger } from "../workspace/status-gates";
17
+ import { resolutionAnswering } from "./gate-resume";
18
+
19
+ const digest = `sha256:${"a".repeat(64)}`;
20
+ const approval = (who: string, at: string, extra: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
21
+ version: 1,
22
+ op: "ship",
23
+ gate: "prod",
24
+ resolvedBy: who,
25
+ timestamp: at,
26
+ planDigest: digest,
27
+ ...extra,
28
+ });
29
+
30
+ /** A judge that proves the names in `proven`, gives roles from `roles`, and knows `authors`. */
31
+ const judgeOf = (proven: string[], roles: Record<string, string[]>, authors?: string[]): GateQuorumJudge => (r) => {
32
+ const ok = proven.includes(r.resolvedBy);
33
+ return { proven: ok, roles: ok ? rolesOf(r.resolvedBy, roles) : [], ...(authors ? { author: authors.includes(r.resolvedBy) } : {}) };
34
+ };
35
+
36
+ describe("approval rules (#4290)", () => {
37
+ test("targets globs read * within a segment and ** across segments", () => {
38
+ expect(targetGlobMatch("prod-*", "prod-eu")).toBe(true);
39
+ expect(targetGlobMatch("prod-*", "staging")).toBe(false);
40
+ expect(targetGlobMatch("envs/prod/**", "envs/prod/eu/db")).toBe(true);
41
+ expect(targetGlobMatch("envs/*/db", "envs/prod/eu/db")).toBe(false);
42
+ expect(targetGlobMatch("envs/**/db", "envs/db")).toBe(true);
43
+ });
44
+
45
+ test("a gate takes the rules with no targets, and those a glob of matches one of its targets", () => {
46
+ const rules: GateQuorumRule[] = [{ count: 1 }, { targets: ["prod-*"], count: 2 }, { targets: ["dev"], count: 3 }];
47
+ expect(quorumRulesFor(rules, ["prod-eu", "staging"])).toEqual([{ count: 1 }, { targets: ["prod-*"], count: 2 }]);
48
+ expect(quorumRulesFor(rules, [])).toEqual([{ count: 1 }]);
49
+ });
50
+
51
+ test("a role counts only for a proven approver that trust.json grants it", () => {
52
+ const rule: GateQuorumRule = { count: 2, roles: ["platform"] };
53
+ const judge = judgeOf(["github:alice", "github:bob"], { platform: ["github:alice", "github:carol"] });
54
+ const approvers = ["github:alice", "github:bob", "github:carol"].map((who, i) => ({ who, at: `2026-10-22T0${i}:00:00Z`, judged: judge(approval(who, "")) }));
55
+ const p = quorumRuleProgress(rule, approvers);
56
+ // bob is proven but holds no role; carol holds the role but is not proven.
57
+ expect(p.approvers).toEqual(["github:alice"]);
58
+ expect(p.met).toBe(false);
59
+ expect(quorumRuleLine(p)).toBe("every target: 1 of 2 approvals (github:alice), needs 1 more from platform");
60
+ });
61
+
62
+ test("notAuthor needs a proven approver who is not the author, and waits when the author is unknown", () => {
63
+ const rule: GateQuorumRule = { count: 1, notAuthor: true };
64
+ const at = "2026-10-22T00:00:00Z";
65
+ const byAuthor = judgeOf(["github:alice"], {}, ["github:alice"]);
66
+ expect(quorumRuleProgress(rule, [{ who: "github:alice", at, judged: byAuthor(approval("github:alice", at)) }]).met).toBe(false);
67
+ const byOther = judgeOf(["github:bob"], {}, ["github:alice"]);
68
+ expect(quorumRuleProgress(rule, [{ who: "github:bob", at, judged: byOther(approval("github:bob", at)) }]).met).toBe(true);
69
+ const unknown = judgeOf(["github:bob"], {});
70
+ const p = quorumRuleProgress(rule, [{ who: "github:bob", at, judged: unknown(approval("github:bob", at)) }]);
71
+ expect(p).toMatchObject({ met: false, authorUnknown: true });
72
+ expect(quorumRuleLine(p)).toMatch(/author is not known/);
73
+ });
74
+
75
+ test("the trust judge proves a reviewer of the live review, never a claimed role", () => {
76
+ const judge = trustQuorumJudge({
77
+ signers: { base: null, signersPath: ".chant/allowed_signers", active: false, signers: [], problems: [] },
78
+ roles: { platform: ["github:alice"] },
79
+ authors: ["github:dana"],
80
+ review: { head: "h1", approvers: ["github:alice"] },
81
+ });
82
+ expect(judge(approval("github:alice", "", { review: { pr: 1, head: "h1", approvers: ["github:alice"] } }))).toEqual({ proven: true, roles: ["platform"], author: false });
83
+ // A review line for another head proves nothing, and a claimed role is ignored.
84
+ expect(judge(approval("github:alice", "", { review: { pr: 1, head: "h0", approvers: ["github:alice"] }, approver: { kind: "human", roles: ["platform"] } }))).toEqual({ proven: false, roles: [], author: false });
85
+ });
86
+
87
+ test("the tally passes once every rule is met, each approver once", () => {
88
+ const rules: GateQuorumRule[] = [{ count: 2 }, { count: 1, roles: ["platform"] }];
89
+ const judge = judgeOf(["github:alice"], { platform: ["github:alice"] });
90
+ const records = [approval("github:bob", "2026-10-22T01:00:00Z"), approval("github:bob", "2026-10-22T02:00:00Z")];
91
+ const one = tallyGateApprovals(records, "prod", "2026-10-22T00:00:00Z", digest, { mode: "log-only", quorum: rules }, undefined, judge);
92
+ expect(one).toMatchObject({ met: false, need: 2 });
93
+ expect(one.counted.map((r) => r.resolvedBy)).toEqual(["github:bob"]);
94
+ const both = tallyGateApprovals([...records, approval("github:alice", "2026-10-22T03:00:00Z")], "prod", "2026-10-22T00:00:00Z", digest, { mode: "log-only", quorum: rules }, undefined, judge);
95
+ expect(both.met).toBe(true);
96
+ expect(both.rules?.map((r) => r.met)).toEqual([true, true]);
97
+ });
98
+
99
+ test("an agent never counts toward a rule", () => {
100
+ const records = [approval("bot", "2026-10-22T01:00:00Z", { approver: { kind: "agent" } })];
101
+ expect(tallyGateApprovals(records, "prod", "2026-10-22T00:00:00Z", digest, { mode: "log-only", quorum: [{ count: 1 }] }).met).toBe(false);
102
+ });
103
+
104
+ test("evaluateGate asks the port's judge for rules naming roles, and with none no role counts", async () => {
105
+ const quorum: GateQuorumRule[] = [{ count: 1, roles: ["platform"] }];
106
+ const seeded = { resolutions: [approval("github:alice", "2026-10-22T01:00:00Z", { approver: { kind: "human", roles: ["platform"] } })] };
107
+ const input = { op: "ship", gate: "prod", planDigest: digest, approval: { mode: "log-only" as const, quorum }, now: "2026-10-22T02:00:00Z" };
108
+ const bare = await evaluateGate(memoryGateLedgerPort(seeded), input);
109
+ expect(bare.satisfied).toBe(false);
110
+ if (!bare.satisfied) expect(bare.quorum?.rules?.[0]).toMatchObject({ met: false, approvers: [] });
111
+ const port = { ...memoryGateLedgerPort(seeded), quorumJudge: async () => judgeOf(["github:alice"], { platform: ["github:alice"] }) };
112
+ expect((await evaluateGate(port, input)).satisfied).toBe(true);
113
+ });
114
+
115
+ test("an Op gate's block takes a list of rules, and refuses notAuthor", () => {
116
+ expect(gateApprovalProblems({ quorum: [{ targets: ["prod"], count: 2, roles: ["platform"] }, { count: 1 }] })).toEqual([]);
117
+ expect(gateApprovalProblems({ quorum: [{ count: 1, notAuthor: true }] })).toEqual([expect.stringMatching(/notAuthor` is not read here/)]);
118
+ expect(gateApprovalProblems({ quorum: [] })).toEqual(["`approval.quorum` must name at least one rule"]);
119
+ expect(quorumProblems([{ count: 0, targets: [], who: 1 }], "approvals.quorum", { notAuthor: true })).toEqual([
120
+ "`approvals.quorum[0].count` must be an integer of at least 1",
121
+ "`approvals.quorum[0].targets` must be a non-empty list of globs",
122
+ "`approvals.quorum[0].who` is not a quorum field; use targets, count, roles or notAuthor",
123
+ ]);
124
+ });
125
+
126
+ test("workspace status and the resume check count a role rule with the judge, as the run does", () => {
127
+ const quorum: GateQuorumRule[] = [{ count: 1, roles: ["platform"] }];
128
+ const standing: PendingGateRecord = {
129
+ version: 1, kind: "pending", op: "ship", gate: "prod", timestamp: "2026-10-22T00:00:00Z", expiresAt: "2026-10-24T00:00:00Z", planDigest: digest, approval: { mode: "log-only", quorum },
130
+ };
131
+ const alice = approval("github:alice", "2026-10-22T01:00:00Z");
132
+ const judge = judgeOf(["github:alice"], { platform: ["github:alice"] });
133
+ const content = [standing, alice].map((r) => JSON.stringify(r)).join("\n") + "\n";
134
+ expect(gatesInLedger("ship", content, [], "2026-10-22T02:00:00Z").gates[0]?.state).toBe("pending");
135
+ expect(gatesInLedger("ship", content, [], "2026-10-22T02:00:00Z", () => null, judge).gates[0]?.state).toBe("approved");
136
+ expect(resolutionAnswering([alice], standing)).toBeUndefined();
137
+ expect(resolutionAnswering([alice], standing, [standing], judge)?.resolvedBy).toBe("github:alice");
138
+ });
139
+ });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Approval rules (#4290): a gate's quorum as a list of rules, each with the
3
+ * targets it covers, how many approvers it needs, the roles that count, and
4
+ * whether one of them must not be the change's author.
5
+ *
6
+ * quorum: [
7
+ * { targets: ["prod-*"], count: 2, roles: ["platform"], notAuthor: true },
8
+ * { count: 1 },
9
+ * ]
10
+ *
11
+ * A gate takes every rule whose `targets` glob matches one of its targets
12
+ * (a rule with no `targets` covers every target), and passes once each rule
13
+ * it takes is met by distinct human approvers of its plan. A gate that takes
14
+ * no rule needs one human approval, the rule every gate had before.
15
+ *
16
+ * Roles come from `.chant/trust.json` at base (`roles`: role name to the
17
+ * principals that hold it), never from the approver's own claim. An
18
+ * approver counts toward `roles`, and as someone who is not the author, only
19
+ * when the approval proves who they are: a seal that verifies against the
20
+ * signers file at base, or under `pr-review` the forge review of the merged
21
+ * pull request. The judge of a gate decides that ({@link GateQuorumJudge});
22
+ * with none, no approval proves anything, so a rule naming roles or
23
+ * `notAuthor` is never met.
24
+ *
25
+ * This module is pure: no git, no file reads, no package, so
26
+ * `@intentius/chant/gate-decision` bundles it. The judge is built by the
27
+ * caller from the trust policy it read at base ({@link trustQuorumJudge}).
28
+ */
29
+
30
+ import { gateSealRefusal, type GateSigners } from "../lifecycle/gate-seal";
31
+ import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
32
+
33
+ /** One approval rule. */
34
+ export interface GateQuorumRule {
35
+ /** Globs over the gate's targets (an Op wave's run targets, an Op gate's environment). Absent, the rule covers every target. */
36
+ targets?: string[];
37
+ /** Distinct human approvers the rule needs. At least 1. */
38
+ count: number;
39
+ /** When set, only an approver `.chant/trust.json` at base gives one of these roles counts, and only with a proven approval. */
40
+ roles?: string[];
41
+ /** When true, at least one counted approver is proven and is not the change's author. */
42
+ notAuthor?: boolean;
43
+ }
44
+
45
+ /** A gate's quorum: one rule (the form before #4290), or a list of them. */
46
+ export type GateQuorumSpec = GateQuorumRule | readonly GateQuorumRule[];
47
+
48
+ /** What a judge says about one approval. */
49
+ export interface QuorumJudgement {
50
+ /** The approval proves who `resolvedBy` is: a verified seal, or the forge's review. */
51
+ proven: boolean;
52
+ /** The roles `.chant/trust.json` at base gives the approver. Empty unless `proven`. */
53
+ roles: string[];
54
+ /** Whether the approver is the change's author. Undefined when nobody knows who the author is. */
55
+ author?: boolean;
56
+ }
57
+
58
+ /** Judges one approval for the rules. */
59
+ export type GateQuorumJudge = (approval: GateResolutionRecord) => QuorumJudgement;
60
+
61
+ /** The judge with nothing to judge by: no approval proves anyone, and nobody's author is known. */
62
+ export const UNPROVEN_JUDGE: GateQuorumJudge = () => ({ proven: false, roles: [] });
63
+
64
+ /** How far one rule is met. */
65
+ export interface GateQuorumRuleProgress {
66
+ targets?: string[];
67
+ count: number;
68
+ roles?: string[];
69
+ notAuthor?: boolean;
70
+ /** The approvers it counts, oldest first. */
71
+ approvers: string[];
72
+ met: boolean;
73
+ /** Why the author could not be told apart, when `notAuthor` is set and nobody knows the author. */
74
+ authorUnknown?: boolean;
75
+ }
76
+
77
+ /** The rules of a quorum, as a list. Absent, none. */
78
+ export function quorumRules(quorum: GateQuorumSpec | undefined): GateQuorumRule[] {
79
+ if (quorum === undefined) return [];
80
+ return Array.isArray(quorum) ? [...(quorum as readonly GateQuorumRule[])] : [quorum as GateQuorumRule];
81
+ }
82
+
83
+ /**
84
+ * Whether `target` matches `glob`: `*` within a `/` segment, `**` across
85
+ * segments (and `a/**\/b` across none), `?` one character. The same reading
86
+ * as the terraform lexicon's unit globs, so a Terragrunt root's glob means
87
+ * one thing in both.
88
+ */
89
+ export function targetGlobMatch(glob: string, target: string): boolean {
90
+ const g = glob.replace(/\\/g, "/").replace(/\/+$/, "");
91
+ let re = "";
92
+ for (let i = 0; i < g.length; i++) {
93
+ const c = g[i]!;
94
+ if (c === "*" && g[i + 1] === "*") {
95
+ if (g[i + 2] === "/") {
96
+ re += "(?:.*/)?";
97
+ i += 2;
98
+ } else {
99
+ re += ".*";
100
+ i += 1;
101
+ }
102
+ } else if (c === "*") re += "[^/]*";
103
+ else if (c === "?") re += "[^/]";
104
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
105
+ }
106
+ return new RegExp(`^${re}$`).test(target.replace(/\\/g, "/").replace(/\/+$/, ""));
107
+ }
108
+
109
+ /** The rules a gate whose targets are `targets` takes: those with no `targets`, and those a glob of matches one. */
110
+ export function quorumRulesFor(rules: readonly GateQuorumRule[], targets: readonly string[]): GateQuorumRule[] {
111
+ return rules.filter((r) => r.targets === undefined || r.targets.some((g) => targets.some((t) => targetGlobMatch(g, t))));
112
+ }
113
+
114
+ /** The most approvers any rule needs, 1 with none. */
115
+ export function quorumNeed(rules: readonly GateQuorumRule[]): number {
116
+ return rules.reduce((n, r) => Math.max(n, r.count), 1);
117
+ }
118
+
119
+ /**
120
+ * Everything wrong with a quorum, one line each. `where` names it
121
+ * (`approval.quorum`). With `notAuthor: false`, a rule naming `notAuthor` is
122
+ * refused, since that caller has no author to judge by.
123
+ */
124
+ export function quorumProblems(value: unknown, where: string, opts: { notAuthor: boolean }): string[] {
125
+ const list = Array.isArray(value) ? value : [value];
126
+ const problems: string[] = [];
127
+ if (Array.isArray(value) && value.length === 0) problems.push(`\`${where}\` must name at least one rule`);
128
+ list.forEach((rule, i) => {
129
+ const at = Array.isArray(value) ? `${where}[${i}]` : where;
130
+ if (typeof rule !== "object" || rule === null || Array.isArray(rule)) {
131
+ problems.push(`\`${at}\` must be an object`);
132
+ return;
133
+ }
134
+ const q = rule as Record<string, unknown>;
135
+ if (typeof q.count !== "number" || !Number.isInteger(q.count) || q.count < 1) problems.push(`\`${at}.count\` must be an integer of at least 1`);
136
+ if (q.roles !== undefined && (!Array.isArray(q.roles) || q.roles.length === 0 || q.roles.some((r) => typeof r !== "string" || r === ""))) {
137
+ problems.push(`\`${at}.roles\` must be a non-empty list of role names`);
138
+ }
139
+ if (q.targets !== undefined && (!Array.isArray(q.targets) || q.targets.length === 0 || q.targets.some((t) => typeof t !== "string" || t === ""))) {
140
+ problems.push(`\`${at}.targets\` must be a non-empty list of globs`);
141
+ }
142
+ if (q.notAuthor !== undefined && typeof q.notAuthor !== "boolean") problems.push(`\`${at}.notAuthor\` must be true or false`);
143
+ if (q.notAuthor === true && !opts.notAuthor) {
144
+ problems.push(`\`${at}.notAuthor\` is not read here: an Op gate has no author to judge by. An Op waves spec's \`approvals.quorum\` reads it from the merged pull request`);
145
+ }
146
+ const known = new Set(["targets", "count", "roles", "notAuthor"]);
147
+ for (const key of Object.keys(q)) if (!known.has(key)) problems.push(`\`${at}.${key}\` is not a quorum field; use targets, count, roles or notAuthor`);
148
+ });
149
+ return problems;
150
+ }
151
+
152
+ const norm = (s: string): string => s.normalize("NFKC").trim().toLowerCase();
153
+
154
+ /** Whether two principals name the same person, compared trimmed and lower-cased. */
155
+ export const samePrincipal = (a: string, b: string): boolean => norm(a) === norm(b);
156
+
157
+ /** The roles `roles` (`.chant/trust.json`'s role grants) gives `who`, sorted. */
158
+ export function rolesOf(who: string, roles: Readonly<Record<string, readonly string[]>>): string[] {
159
+ return Object.entries(roles)
160
+ .filter(([, principals]) => principals.some((p) => samePrincipal(p, who)))
161
+ .map(([role]) => role)
162
+ .sort();
163
+ }
164
+
165
+ /** One approver as a rule counts them. */
166
+ export interface QuorumApprover {
167
+ who: string;
168
+ at: string;
169
+ judged: QuorumJudgement;
170
+ }
171
+
172
+ /** How far `rule` is met by `approvers`: each counted once, by name, oldest first. */
173
+ export function quorumRuleProgress(rule: GateQuorumRule, approvers: readonly QuorumApprover[]): GateQuorumRuleProgress {
174
+ const seen = new Set<string>();
175
+ const counted: QuorumApprover[] = [];
176
+ for (const a of [...approvers].sort((x, y) => Date.parse(x.at) - Date.parse(y.at))) {
177
+ if (seen.has(norm(a.who))) continue;
178
+ if (rule.roles?.length && !(a.judged.proven && a.judged.roles.some((r) => rule.roles!.includes(r)))) continue;
179
+ seen.add(norm(a.who));
180
+ counted.push(a);
181
+ }
182
+ const notAuthor = !rule.notAuthor || counted.some((a) => a.judged.proven && a.judged.author === false);
183
+ const authorUnknown = rule.notAuthor === true && !notAuthor && counted.some((a) => a.judged.proven && a.judged.author === undefined);
184
+ return {
185
+ ...(rule.targets ? { targets: [...rule.targets] } : {}),
186
+ count: rule.count,
187
+ ...(rule.roles?.length ? { roles: [...rule.roles] } : {}),
188
+ ...(rule.notAuthor ? { notAuthor: true } : {}),
189
+ approvers: counted.map((a) => a.who),
190
+ met: counted.length >= rule.count && notAuthor,
191
+ ...(authorUnknown ? { authorUnknown: true } : {}),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Where a rule stands, in one line: `prod-*: 1 of 2 approvals (github:alice),
197
+ * needs 1 more from platform`.
198
+ */
199
+ export function quorumRuleLine(p: GateQuorumRuleProgress): string {
200
+ const scope = p.targets ? p.targets.join(", ") : "every target";
201
+ const have = `${p.approvers.length} of ${p.count} approval${p.count === 1 ? "" : "s"}${p.approvers.length ? ` (${p.approvers.join(", ")})` : ""}`;
202
+ if (p.met) return `${scope}: ${have}, met`;
203
+ const more = p.count - p.approvers.length;
204
+ const needs: string[] = [];
205
+ if (more > 0) needs.push(`${more} more${p.roles?.length ? ` from ${p.roles.join(" or ")}` : ""}`);
206
+ if (p.notAuthor) needs.push(more > 0 ? "one of them not the author" : "an approver who is not the author");
207
+ const unknown = p.authorUnknown ? " (the change's author is not known, so no approver can show they are someone else)" : "";
208
+ return `${scope}: ${have}, needs ${needs.join(", ")}${unknown}`;
209
+ }
210
+
211
+ /** The rules that are not met, one line each joined by `; `. */
212
+ export function quorumWaiting(progress: readonly GateQuorumRuleProgress[]): string {
213
+ return progress.filter((p) => !p.met).map(quorumRuleLine).join("; ");
214
+ }
215
+
216
+ /** Whether a rule needs a proven approver: it names roles, or `notAuthor`. */
217
+ export const ruleNeedsProof = (rule: GateQuorumRule): boolean => (rule.roles?.length ?? 0) > 0 || rule.notAuthor === true;
218
+
219
+ /** What {@link trustQuorumJudge} judges by. */
220
+ export interface TrustQuorumInput {
221
+ /** The signers at base: an approval whose seal verifies for its approver against them is proven. */
222
+ signers: GateSigners;
223
+ /** `.chant/trust.json`'s role grants at base. */
224
+ roles: Readonly<Record<string, readonly string[]>>;
225
+ /** The change's authors, as principals. Absent, nobody knows who they are. */
226
+ authors?: readonly string[];
227
+ /**
228
+ * Under `pr-review`, the merged pull request's review, read from the forge
229
+ * in this run: a resolution that names this review's head and one of its
230
+ * approvers as `resolvedBy` is proven.
231
+ */
232
+ review?: { head: string; approvers: readonly string[] };
233
+ }
234
+
235
+ /** The judge a gate's rules are met by: proof from a seal or the live review, roles from trust.json at base. */
236
+ export function trustQuorumJudge(input: TrustQuorumInput): GateQuorumJudge {
237
+ return (r) => {
238
+ const sealed = r.seal !== undefined && r.seal !== null && gateSealRefusal(input.signers, r) === null;
239
+ const reviewed =
240
+ input.review !== undefined && r.review?.head === input.review.head && input.review.approvers.some((a) => samePrincipal(a, r.resolvedBy));
241
+ const proven = sealed || reviewed;
242
+ return {
243
+ proven,
244
+ roles: proven ? rolesOf(r.resolvedBy, input.roles) : [],
245
+ ...(input.authors !== undefined ? { author: input.authors.some((a) => samePrincipal(a, r.resolvedBy)) } : {}),
246
+ };
247
+ };
248
+ }
@@ -11,6 +11,7 @@
11
11
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
12
12
  import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
13
13
  import { humanQuorumSince, tallyGateApprovals } from "./gate";
14
+ import type { GateQuorumJudge } from "./gate-quorum";
14
15
 
15
16
  export {
16
17
  isGateRunLocator,
@@ -28,19 +29,21 @@ export {
28
29
  * gate ledger's pending facts, which place the human quorum's anchor (#3206);
29
30
  * without them it is `standing` itself. A workspace's
30
31
  * `identity.gates` rule is not applied here: the resumed job applies it, and
31
- * waits again when the approval does not count.
32
+ * waits again when the approval does not count. `judge` proves approvers
33
+ * for approval rules that name roles (#4290); absent, none is proven.
32
34
  */
33
35
  export function resolutionAnswering(
34
36
  resolutions: readonly GateResolutionRecord[],
35
37
  standing: PendingGateRecord,
36
38
  pending: readonly PendingGateRecord[] = [standing],
39
+ judge?: GateQuorumJudge,
37
40
  ): GateResolutionRecord | undefined {
38
41
  const env = standing.environment;
39
42
  const mine = resolutions.filter((r) => env === undefined || r.environment === env);
40
43
  if (standing.approval) {
41
- const tally = tallyGateApprovals(mine, standing.gate, standing.timestamp, standing.planDigest, standing.approval, humanQuorumSince(pending, standing));
44
+ const tally = tallyGateApprovals(mine, standing.gate, standing.timestamp, standing.planDigest, standing.approval, humanQuorumSince(pending, standing), judge);
42
45
  if (tally.permit) return tally.permit;
43
- return tally.counted.length >= tally.need ? tally.counted[tally.counted.length - 1] : undefined;
46
+ return tally.met ? tally.counted[tally.counted.length - 1] : undefined;
44
47
  }
45
48
  return latestResolutionForPlan(mine, standing.gate, standing.timestamp, standing.planDigest).resolution;
46
49
  }
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import { readFileSync } from "node:fs";
33
- import { gateSealRefusal } from "../workspace/trust/gate-seal";
33
+ import { gateSealRefusal } from "../lifecycle/gate-seal";
34
34
  import { readTrustPolicy, type TrustPolicy } from "../workspace/trust/policy";
35
35
  import { gitRevisionSource } from "../workspace/record-source";
36
36
  import { forgePrincipalOf, ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";