@intentius/chant 0.124.40 → 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 (281) 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/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +15 -1
  15. package/dist/cli/handlers/fan-out.js +16 -3
  16. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  17. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  18. package/dist/cli/handlers/gate-resume.js +15 -2
  19. package/dist/cli/handlers/lock.d.ts +8 -0
  20. package/dist/cli/handlers/lock.d.ts.map +1 -0
  21. package/dist/cli/handlers/lock.js +197 -0
  22. package/dist/cli/handlers/pr-env.d.ts +34 -0
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  24. package/dist/cli/handlers/pr-env.js +436 -0
  25. package/dist/cli/handlers/pr.d.ts +74 -0
  26. package/dist/cli/handlers/pr.d.ts.map +1 -1
  27. package/dist/cli/handlers/pr.js +503 -53
  28. package/dist/cli/handlers/promote.d.ts.map +1 -1
  29. package/dist/cli/handlers/promote.js +9 -0
  30. package/dist/cli/handlers/query.d.ts +6 -0
  31. package/dist/cli/handlers/query.d.ts.map +1 -0
  32. package/dist/cli/handlers/query.js +80 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/main.js +41 -2
  35. package/dist/cli/parse-args.d.ts.map +1 -1
  36. package/dist/cli/parse-args.js +31 -2
  37. package/dist/cli/registry.d.ts +12 -2
  38. package/dist/cli/registry.d.ts.map +1 -1
  39. package/dist/components/fan-out-run.d.ts +28 -0
  40. package/dist/components/fan-out-run.d.ts.map +1 -1
  41. package/dist/components/fan-out-run.js +27 -3
  42. package/dist/components/pr-environment.d.ts +298 -0
  43. package/dist/components/pr-environment.d.ts.map +1 -0
  44. package/dist/components/pr-environment.js +406 -0
  45. package/dist/components/pr-hooks.d.ts +66 -0
  46. package/dist/components/pr-hooks.d.ts.map +1 -0
  47. package/dist/components/pr-hooks.js +119 -0
  48. package/dist/components/pr-pipeline.d.ts +72 -2
  49. package/dist/components/pr-pipeline.d.ts.map +1 -1
  50. package/dist/components/pr-pipeline.js +96 -5
  51. package/dist/components/pr-policy.d.ts +109 -0
  52. package/dist/components/pr-policy.d.ts.map +1 -0
  53. package/dist/components/pr-policy.js +150 -0
  54. package/dist/components/pr-run.d.ts.map +1 -1
  55. package/dist/components/pr-run.js +5 -3
  56. package/dist/gate-decision.d.ts +15 -2
  57. package/dist/gate-decision.d.ts.map +1 -1
  58. package/dist/gate-decision.js +17 -2
  59. package/dist/gate-policy.d.ts +140 -0
  60. package/dist/gate-policy.d.ts.map +1 -0
  61. package/dist/gate-policy.js +242 -0
  62. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  63. package/dist/lifecycle/gate-checkpoint.js +1 -1
  64. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  65. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  66. package/dist/lifecycle/query.d.ts +68 -0
  67. package/dist/lifecycle/query.d.ts.map +1 -0
  68. package/dist/lifecycle/query.js +246 -0
  69. package/dist/lifecycle/run-ledger.d.ts +13 -0
  70. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  71. package/dist/lifecycle/run-ledger.js +16 -0
  72. package/dist/lifecycle/target-lock.d.ts +97 -0
  73. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  74. package/dist/lifecycle/target-lock.js +286 -0
  75. package/dist/op/activities/activity-contracts.d.ts +30 -0
  76. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  77. package/dist/op/activities/activity-contracts.js +26 -0
  78. package/dist/op/activities/index.d.ts +2 -0
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/op/activities/index.js +4 -0
  81. package/dist/op/activities/review-agent.d.ts +137 -0
  82. package/dist/op/activities/review-agent.d.ts.map +1 -0
  83. package/dist/op/activities/review-agent.js +266 -0
  84. package/dist/op/builders.d.ts +9 -0
  85. package/dist/op/builders.d.ts.map +1 -1
  86. package/dist/op/builders.js +11 -0
  87. package/dist/op/cron.d.ts +8 -0
  88. package/dist/op/cron.d.ts.map +1 -1
  89. package/dist/op/cron.js +51 -0
  90. package/dist/op/gate-approval.d.ts +20 -13
  91. package/dist/op/gate-approval.d.ts.map +1 -1
  92. package/dist/op/gate-approval.js +13 -18
  93. package/dist/op/gate-quorum.d.ts +131 -0
  94. package/dist/op/gate-quorum.d.ts.map +1 -0
  95. package/dist/op/gate-quorum.js +183 -0
  96. package/dist/op/gate-resume.d.ts +4 -2
  97. package/dist/op/gate-resume.d.ts.map +1 -1
  98. package/dist/op/gate-resume.js +5 -4
  99. package/dist/op/gate-review.js +1 -1
  100. package/dist/op/gate.d.ts +32 -4
  101. package/dist/op/gate.d.ts.map +1 -1
  102. package/dist/op/gate.js +40 -24
  103. package/dist/op/index.d.ts +6 -4
  104. package/dist/op/index.d.ts.map +1 -1
  105. package/dist/op/index.js +4 -3
  106. package/dist/op/local-executor.d.ts.map +1 -1
  107. package/dist/op/local-executor.js +9 -3
  108. package/dist/op/op-waves-run.d.ts +62 -1
  109. package/dist/op/op-waves-run.d.ts.map +1 -1
  110. package/dist/op/op-waves-run.js +190 -11
  111. package/dist/op/op-waves.d.ts +55 -3
  112. package/dist/op/op-waves.d.ts.map +1 -1
  113. package/dist/op/op-waves.js +63 -8
  114. package/dist/op/schedule-overdue.d.ts +57 -0
  115. package/dist/op/schedule-overdue.d.ts.map +1 -0
  116. package/dist/op/schedule-overdue.js +138 -0
  117. package/dist/override-gate.d.ts +2 -2
  118. package/dist/override-gate.d.ts.map +1 -1
  119. package/dist/override-gate.js +1 -1
  120. package/dist/pr-forge.d.ts +36 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +82 -0
  123. package/dist/pr-loop.d.ts +90 -1
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +77 -3
  126. package/dist/pr-post.d.ts +72 -0
  127. package/dist/pr-post.d.ts.map +1 -0
  128. package/dist/pr-post.js +134 -0
  129. package/dist/wave-blocked.d.ts +79 -0
  130. package/dist/wave-blocked.d.ts.map +1 -0
  131. package/dist/wave-blocked.js +245 -0
  132. package/dist/wave-engine.d.ts +6 -0
  133. package/dist/wave-engine.d.ts.map +1 -1
  134. package/dist/wave-engine.js +2 -0
  135. package/dist/wave-runtime.mjs +242 -242
  136. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  137. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  138. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  139. package/dist/workspace/ci-branches.d.ts +51 -0
  140. package/dist/workspace/ci-branches.d.ts.map +1 -0
  141. package/dist/workspace/ci-branches.js +69 -0
  142. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  143. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  144. package/dist/workspace/ci-cloud-identity.js +24 -0
  145. package/dist/workspace/ci-locks.d.ts +12 -0
  146. package/dist/workspace/ci-locks.d.ts.map +1 -0
  147. package/dist/workspace/ci-locks.js +6 -0
  148. package/dist/workspace/ci-run.d.ts +17 -1
  149. package/dist/workspace/ci-run.d.ts.map +1 -1
  150. package/dist/workspace/ci-run.js +64 -3
  151. package/dist/workspace/declaration.d.ts +63 -0
  152. package/dist/workspace/declaration.d.ts.map +1 -1
  153. package/dist/workspace/declaration.js +72 -1
  154. package/dist/workspace/declaration.schema.json +147 -0
  155. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  156. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  157. package/dist/workspace/identity-gate-rule.js +1 -1
  158. package/dist/workspace/identity.d.ts +1 -1
  159. package/dist/workspace/identity.d.ts.map +1 -1
  160. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  161. package/dist/workspace/lineage-upgrade.js +8 -5
  162. package/dist/workspace/member-pipeline.d.ts +6 -0
  163. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  164. package/dist/workspace/member-pipeline.js +23 -0
  165. package/dist/workspace/pr-report.schema.json +73 -2
  166. package/dist/workspace/security.d.ts.map +1 -1
  167. package/dist/workspace/security.js +4 -0
  168. package/dist/workspace/status-gates.d.ts +3 -2
  169. package/dist/workspace/status-gates.d.ts.map +1 -1
  170. package/dist/workspace/status-gates.js +8 -7
  171. package/dist/workspace/status.d.ts.map +1 -1
  172. package/dist/workspace/status.js +4 -1
  173. package/dist/workspace/trust/seal.d.ts +2 -2
  174. package/dist/workspace/trust/seal.d.ts.map +1 -1
  175. package/dist/workspace/trust/seal.js +2 -2
  176. package/package.json +1 -1
  177. package/src/__fixtures__/oidc-emulator.ts +118 -0
  178. package/src/ci-handover.test.ts +209 -0
  179. package/src/ci-handover.ts +237 -0
  180. package/src/ci-identity.test.ts +201 -0
  181. package/src/ci-identity.ts +310 -0
  182. package/src/cli/handlers/approve.ts +18 -5
  183. package/src/cli/handlers/build.ts +15 -1
  184. package/src/cli/handlers/fan-out.ts +17 -4
  185. package/src/cli/handlers/gate-resume.ts +16 -2
  186. package/src/cli/handlers/lock.test.ts +98 -0
  187. package/src/cli/handlers/lock.ts +202 -0
  188. package/src/cli/handlers/operator.test.ts +3 -1
  189. package/src/cli/handlers/pr-env.ts +462 -0
  190. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  191. package/src/cli/handlers/pr.test.ts +184 -0
  192. package/src/cli/handlers/pr.ts +496 -59
  193. package/src/cli/handlers/promote.ts +9 -0
  194. package/src/cli/handlers/query.ts +78 -0
  195. package/src/cli/main.ts +41 -2
  196. package/src/cli/parse-args.ts +23 -2
  197. package/src/cli/registry.ts +12 -2
  198. package/src/components/fan-out-run.ts +39 -2
  199. package/src/components/fan-out-waves.test.ts +45 -1
  200. package/src/components/pr-environment.test.ts +249 -0
  201. package/src/components/pr-environment.ts +538 -0
  202. package/src/components/pr-hooks.test.ts +118 -0
  203. package/src/components/pr-hooks.ts +157 -0
  204. package/src/components/pr-pipeline.ts +130 -5
  205. package/src/components/pr-policy.test.ts +145 -0
  206. package/src/components/pr-policy.ts +211 -0
  207. package/src/components/pr-run.ts +5 -2
  208. package/src/gate-decision-bundle.test.ts +4 -3
  209. package/src/gate-decision.ts +39 -2
  210. package/src/gate-policy-bundle.test.ts +28 -0
  211. package/src/gate-policy.test.ts +162 -0
  212. package/src/gate-policy.ts +298 -0
  213. package/src/lifecycle/gate-checkpoint.ts +1 -1
  214. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  215. package/src/lifecycle/query.test.ts +91 -0
  216. package/src/lifecycle/query.ts +315 -0
  217. package/src/lifecycle/run-ledger.ts +18 -0
  218. package/src/lifecycle/target-lock.test.ts +195 -0
  219. package/src/lifecycle/target-lock.ts +347 -0
  220. package/src/op/activities/activity-contracts.ts +31 -0
  221. package/src/op/activities/index.ts +6 -0
  222. package/src/op/activities/review-agent.test.ts +240 -0
  223. package/src/op/activities/review-agent.ts +355 -0
  224. package/src/op/builders.ts +13 -0
  225. package/src/op/cron.ts +44 -0
  226. package/src/op/gate-alias.test.ts +2 -2
  227. package/src/op/gate-approval.test.ts +30 -9
  228. package/src/op/gate-approval.ts +23 -26
  229. package/src/op/gate-quorum.test.ts +139 -0
  230. package/src/op/gate-quorum.ts +248 -0
  231. package/src/op/gate-resume.ts +6 -3
  232. package/src/op/gate-review.ts +1 -1
  233. package/src/op/gate.ts +64 -23
  234. package/src/op/index.ts +13 -1
  235. package/src/op/local-executor.ts +9 -2
  236. package/src/op/op-waves-hook-targets.test.ts +126 -0
  237. package/src/op/op-waves-policy-hold.test.ts +66 -0
  238. package/src/op/op-waves-quorum.test.ts +101 -0
  239. package/src/op/op-waves-run.ts +233 -11
  240. package/src/op/op-waves.ts +83 -10
  241. package/src/op/schedule-overdue.test.ts +220 -0
  242. package/src/op/schedule-overdue.ts +161 -0
  243. package/src/override-gate-bundle.test.ts +1 -1
  244. package/src/override-gate.ts +2 -2
  245. package/src/pr-forge-bundle.test.ts +2 -2
  246. package/src/pr-forge.test.ts +33 -0
  247. package/src/pr-forge.ts +114 -0
  248. package/src/pr-loop-bundle.test.ts +2 -2
  249. package/src/pr-loop.test.ts +58 -0
  250. package/src/pr-loop.ts +146 -4
  251. package/src/pr-post.test.ts +141 -0
  252. package/src/pr-post.ts +159 -0
  253. package/src/wave-blocked-bundle.test.ts +25 -0
  254. package/src/wave-blocked.test.ts +146 -0
  255. package/src/wave-blocked.ts +271 -0
  256. package/src/wave-engine.ts +8 -0
  257. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  258. package/src/workspace/ci-branches.test.ts +62 -0
  259. package/src/workspace/ci-branches.ts +79 -0
  260. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  261. package/src/workspace/ci-cloud-identity.ts +23 -0
  262. package/src/workspace/ci-locks.ts +16 -0
  263. package/src/workspace/ci-run-index.test.ts +2 -1
  264. package/src/workspace/ci-run.test.ts +107 -8
  265. package/src/workspace/ci-run.ts +72 -4
  266. package/src/workspace/declaration.schema.json +147 -0
  267. package/src/workspace/declaration.test.ts +76 -0
  268. package/src/workspace/declaration.ts +134 -2
  269. package/src/workspace/identity-gate-rule.ts +1 -1
  270. package/src/workspace/identity.ts +1 -1
  271. package/src/workspace/lineage-upgrade.ts +11 -8
  272. package/src/workspace/member-pipeline.test.ts +29 -0
  273. package/src/workspace/member-pipeline.ts +30 -0
  274. package/src/workspace/pr-report.schema.json +73 -2
  275. package/src/workspace/security.ts +4 -0
  276. package/src/workspace/status-gates.ts +9 -6
  277. package/src/workspace/status.ts +4 -1
  278. package/src/workspace/trust/gate-seal.test.ts +1 -1
  279. package/src/workspace/trust/seal.ts +2 -2
  280. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  281. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -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";
package/src/op/gate.ts CHANGED
@@ -51,6 +51,10 @@ import { isModelAuthored } from "../lifecycle/gate-origin";
51
51
  import { appliedGatePath, isApprovalSpent, parseAppliedGateLines, type AppliedGateRecord } from "../lifecycle/gate-applied";
52
52
  import { sortedJsonReplacer } from "../utils";
53
53
  import { gateContextDigest, type GateApprover, type ResolvedGateApproval } from "./gate-approval";
54
+ import {
55
+ quorumNeed, quorumRuleProgress, quorumRules, ruleNeedsProof, samePrincipal, trustQuorumJudge, UNPROVEN_JUDGE,
56
+ type GateQuorumJudge, type GateQuorumRuleProgress,
57
+ } from "./gate-quorum";
54
58
  import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requireLifecycleLedger, writeBlobToPath, RefCASConflictError } from "../lifecycle/git";
55
59
  import { parseDuration } from "./duration";
56
60
  import { ciRunIdFromEnv, resolveGateRunLocator } from "./gate-run-locator";
@@ -99,6 +103,14 @@ export interface GateLedgerPort {
99
103
  * Absent, every approval is judged as before.
100
104
  */
101
105
  approvalRule?(gate: string, at?: { base?: string }): Promise<GateApprovalRule | null>;
106
+ /**
107
+ * The judge a gate's approval rules are met by (#4290): which approvals
108
+ * prove their approver (a seal verified against the signers at base) and
109
+ * the roles `.chant/trust.json` at base gives them. `at.base` as for
110
+ * {@link GateLedgerPort.approvalRule}. Absent, no approval proves anyone,
111
+ * so a rule naming roles is never met.
112
+ */
113
+ quorumJudge?(gate: string, at?: { base?: string }): Promise<GateQuorumJudge>;
102
114
  /** The approvals a wave applied under (#3784), from `_gates/<op>/applied.jsonl`. Absent on a port that never spends approvals. */
103
115
  readApplied?(op: string): Promise<AppliedGateRecord[]>;
104
116
  /** Record that a wave is about to apply under an approval (#3784), which spends it. */
@@ -152,6 +164,12 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
152
164
  const { gateAdmission } = await import("../workspace/identity");
153
165
  return gateAdmission(opts?.cwd ?? process.cwd(), gate, at?.base);
154
166
  },
167
+ async quorumJudge(_gate, at) {
168
+ // #4290: the signers and role grants at base, never the approver's claim.
169
+ const { scopeSource } = await import("../workspace/write-scope");
170
+ const { policy } = scopeSource(opts?.cwd ?? process.cwd(), at?.base);
171
+ return trustQuorumJudge({ signers: policy, roles: policy.roles });
172
+ },
155
173
  async appendResolution(input) {
156
174
  const { record } = await appendGateResolution(input, opts);
157
175
  return { record, ...(await pushLifecycleReport(opts)) };
@@ -293,6 +311,13 @@ export interface GateCheckInput {
293
311
  * it so `chant approve` can evaluate the policy.
294
312
  */
295
313
  approval?: ResolvedGateApproval;
314
+ /**
315
+ * Judges approvals for `approval.quorum`'s rules (#4290). Absent, the
316
+ * port's {@link GateLedgerPort.quorumJudge} at its default base, else no
317
+ * approval proves anyone. A wave passes one that knows the change's author
318
+ * and the merged pull request's review.
319
+ */
320
+ judge?: GateQuorumJudge;
296
321
  /** ISO-8601 "now" — supplied by the caller, so the decision is deterministic under test. */
297
322
  now?: string;
298
323
  /**
@@ -399,6 +424,8 @@ export interface GateQuorumProgress {
399
424
  /** Approvers whose approval counts toward the quorum, oldest first. */
400
425
  approvers: string[];
401
426
  need: number;
427
+ /** Where each approval rule the gate takes stands (#4290). Absent when it takes none. */
428
+ rules?: GateQuorumRuleProgress[];
402
429
  }
403
430
 
404
431
  /** Either the gate is answered, or it is a standing fact. */
@@ -464,8 +491,12 @@ export function approverOf(record: GateResolutionRecord): GateApprover {
464
491
  export interface GateTally {
465
492
  /** Distinct human approvers of this plan who count toward the quorum, one record each (their newest), oldest first. */
466
493
  counted: GateResolutionRecord[];
467
- /** The quorum's count, 1 when the gate declares none. */
494
+ /** The most approvers a rule needs, 1 when the gate declares none. */
468
495
  need: number;
496
+ /** Every rule is met (#4290); with no rules, one human approved. */
497
+ met: boolean;
498
+ /** Where each rule stands (#4290). Absent when the gate takes no rule. */
499
+ rules?: GateQuorumRuleProgress[];
469
500
  /** The newest approval whose recorded permit passes the gate on its own. Only in `enforce` mode, only for a permit recorded under `enforce`, and only under the gate's current policy version and resolved context. */
470
501
  permit?: GateResolutionRecord;
471
502
  /** The newest approval of this gate for a different plan, when the gate binds one. */
@@ -483,8 +514,12 @@ export interface GateTally {
483
514
  * pending fact re-recorded for a changed mode or context keeps a partly
484
515
  * filled quorum (#3206). It defaults to `sinceIso`.
485
516
  *
486
- * Of those, only a human's counts toward the quorum, once per `resolvedBy`,
487
- * and only with one of the quorum's roles when it names any. An agent counts
517
+ * Of those, only a human's counts toward the quorum, once per `resolvedBy`.
518
+ * The quorum is a list of rules (#4290, `./gate-quorum.ts`), already the ones
519
+ * the gate takes: each is met by distinct humans, counting toward `roles`
520
+ * only an approver `judge` proves and gives one of them (roles from
521
+ * `.chant/trust.json` at base, never the approver's claim), and the gate
522
+ * passes when every rule is met. With no rule, one human passes it. An agent counts
488
523
  * only through a recorded `allow` in `enforce` mode, evaluated under the
489
524
  * policy version the gate declares now, recorded while the gate was in
490
525
  * `enforce` (#2512), and evaluated under the context the gate resolves now
@@ -499,14 +534,16 @@ export function tallyGateApprovals(
499
534
  planDigest: string | undefined,
500
535
  approval: ResolvedGateApproval,
501
536
  humansSinceIso: string = sinceIso,
537
+ judge: GateQuorumJudge = UNPROVEN_JUDGE,
502
538
  ): GateTally {
503
539
  const since = new Date(sinceIso).getTime();
504
540
  const humansSince = Math.min(since, new Date(humansSinceIso).getTime());
505
541
  const at = (r: GateResolutionRecord) => new Date(r.timestamp).getTime();
506
- const roles = approval.quorum?.roles;
542
+ const rules = quorumRules(approval.quorum);
507
543
  const context = gateContextDigest(approval.context);
508
544
 
509
- const byActor = new Map<string, GateResolutionRecord>();
545
+ // Each human once: their newest approval, or their newest proven one when they have one.
546
+ const byActor = new Map<string, { record: GateResolutionRecord; proven: boolean }>();
510
547
  let permit: GateResolutionRecord | undefined;
511
548
  let mismatched: GateResolutionRecord | undefined;
512
549
  for (const r of records) {
@@ -534,20 +571,20 @@ export function tallyGateApprovals(
534
571
  permit = r;
535
572
  }
536
573
 
537
- const approver = approverOf(r);
538
- if (approver.kind !== "human") continue;
539
- if (roles && !(approver.roles ?? []).some((role) => roles.includes(role))) continue;
540
- const prior = byActor.get(r.resolvedBy);
541
- if (!prior || at(r) >= at(prior)) byActor.set(r.resolvedBy, r);
574
+ if (approverOf(r).kind !== "human") continue;
575
+ const key = r.resolvedBy.normalize("NFKC").trim().toLowerCase();
576
+ const proven = rules.some(ruleNeedsProof) && judge(r).proven;
577
+ const prior = byActor.get(key);
578
+ if (!prior || (proven && !prior.proven) || (proven === prior.proven && at(r) >= at(prior.record))) byActor.set(key, { record: r, proven });
542
579
  }
543
580
 
544
- const counted = [...byActor.values()].sort((a, b) => at(a) - at(b));
545
- return {
546
- counted,
547
- need: approval.quorum?.count ?? 1,
548
- ...(permit ? { permit } : {}),
549
- ...(mismatched ? { mismatched } : {}),
550
- };
581
+ const humans = [...byActor.values()].map((v) => v.record).sort((a, b) => at(a) - at(b));
582
+ const extra = { ...(permit ? { permit } : {}), ...(mismatched ? { mismatched } : {}) };
583
+ if (rules.length === 0) return { counted: humans, need: 1, met: humans.length >= 1, ...extra };
584
+ const approvers = humans.map((r) => ({ who: r.resolvedBy, at: r.timestamp, judged: judge(r) }));
585
+ const progress = rules.map((rule) => quorumRuleProgress(rule, approvers));
586
+ const counted = humans.filter((r) => progress.some((p) => p.approvers.some((a) => samePrincipal(a, r.resolvedBy))));
587
+ return { counted, need: quorumNeed(rules), met: progress.every((p) => p.met), rules: progress, ...extra };
551
588
  }
552
589
 
553
590
  /**
@@ -807,11 +844,15 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
807
844
  standing.approval?.policy?.version === input.approval.policy?.version
808
845
  ? humanQuorumSince(pending, standing)
809
846
  : since;
810
- const tally = tallyGateApprovals(resolutions, input.gate, since, input.planDigest, input.approval, humansSince);
847
+ // #4290: who an approval proves, and their roles at base, for rules that read them.
848
+ const judge = quorumRules(input.approval.quorum).some(ruleNeedsProof)
849
+ ? (input.judge ?? (await port.quorumJudge?.(input.gate)) ?? UNPROVEN_JUDGE)
850
+ : UNPROVEN_JUDGE;
851
+ const tally = tallyGateApprovals(resolutions, input.gate, since, input.planDigest, input.approval, humansSince, judge);
811
852
  if (tally.permit) {
812
853
  return { satisfied: true, resolution: tally.permit, via: "policy", approvals: tally.counted };
813
854
  }
814
- if (tally.counted.length >= tally.need) {
855
+ if (tally.met) {
815
856
  return {
816
857
  satisfied: true,
817
858
  resolution: tally.counted[tally.counted.length - 1]!,
@@ -820,19 +861,19 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
820
861
  };
821
862
  }
822
863
  mismatched = tally.mismatched;
823
- quorum = { approvers: tally.counted.map((r) => r.resolvedBy), need: tally.need };
864
+ quorum = { approvers: tally.counted.map((r) => r.resolvedBy), need: tally.need, ...(tally.rules ? { rules: tally.rules } : {}) };
824
865
  // #3949: old and new approvals add up toward one quorum, each approver once.
825
866
  const counted = new Set(tally.counted.map((r) => r.resolvedBy));
826
867
  const extra = fromAliases.filter((r) => !counted.has(r.resolvedBy));
827
868
  if (extra.length > 0) {
828
869
  const asCarried = new Map(extra.map((r) => [{ version: 1, ...carriedApproval(r, input.op, input.gate, now) } as GateResolutionRecord, r]));
829
- const combined = tallyGateApprovals([...resolutions, ...asCarried.keys()], input.gate, since, input.planDigest, input.approval, humansSince);
830
- if (combined.counted.length >= combined.need) {
870
+ const combined = tallyGateApprovals([...resolutions, ...asCarried.keys()], input.gate, since, input.planDigest, input.approval, humansSince, judge);
871
+ if (combined.met) {
831
872
  const written = await carry(combined.counted.filter((r) => asCarried.has(r)).map((r) => asCarried.get(r)!));
832
873
  const approvals = [...combined.counted.filter((r) => !asCarried.has(r)), ...written];
833
874
  return { satisfied: true, resolution: written[written.length - 1]!, via: "alias", approvals, carried: written };
834
875
  }
835
- quorum = { approvers: combined.counted.map((r) => r.resolvedBy), need: combined.need };
876
+ quorum = { approvers: combined.counted.map((r) => r.resolvedBy), need: combined.need, ...(combined.rules ? { rules: combined.rules } : {}) };
836
877
  }
837
878
  } else {
838
879
  const found = latestResolutionForPlan(resolutions, input.gate, since, input.planDigest);