@intentius/chant 0.124.40 → 0.124.42

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 (346) 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 +18 -1
  15. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  16. package/dist/cli/handlers/fan-out.js +42 -5
  17. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  18. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  19. package/dist/cli/handlers/gate-resume.js +15 -2
  20. package/dist/cli/handlers/lock.d.ts +8 -0
  21. package/dist/cli/handlers/lock.d.ts.map +1 -0
  22. package/dist/cli/handlers/lock.js +197 -0
  23. package/dist/cli/handlers/pr-env.d.ts +34 -0
  24. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  25. package/dist/cli/handlers/pr-env.js +436 -0
  26. package/dist/cli/handlers/pr.d.ts +76 -0
  27. package/dist/cli/handlers/pr.d.ts.map +1 -1
  28. package/dist/cli/handlers/pr.js +615 -55
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/promote.js +9 -0
  31. package/dist/cli/handlers/query.d.ts +6 -0
  32. package/dist/cli/handlers/query.d.ts.map +1 -0
  33. package/dist/cli/handlers/query.js +80 -0
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/run.js +11 -0
  36. package/dist/cli/main.d.ts.map +1 -1
  37. package/dist/cli/main.js +49 -4
  38. package/dist/cli/parse-args.d.ts.map +1 -1
  39. package/dist/cli/parse-args.js +46 -2
  40. package/dist/cli/registry.d.ts +31 -2
  41. package/dist/cli/registry.d.ts.map +1 -1
  42. package/dist/components/capability.d.ts +47 -0
  43. package/dist/components/capability.d.ts.map +1 -1
  44. package/dist/components/capability.js +44 -0
  45. package/dist/components/cli-support.d.ts +6 -0
  46. package/dist/components/cli-support.d.ts.map +1 -1
  47. package/dist/components/cli-support.js +8 -1
  48. package/dist/components/driver.d.ts +10 -0
  49. package/dist/components/driver.d.ts.map +1 -1
  50. package/dist/components/driver.js +52 -2
  51. package/dist/components/fan-out-run.d.ts +37 -0
  52. package/dist/components/fan-out-run.d.ts.map +1 -1
  53. package/dist/components/fan-out-run.js +44 -4
  54. package/dist/components/gate-plan.d.ts +2 -0
  55. package/dist/components/gate-plan.d.ts.map +1 -1
  56. package/dist/components/gate-plan.js +1 -0
  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 +92 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +150 -21
  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 +6 -0
  70. package/dist/components/pr-run.d.ts.map +1 -1
  71. package/dist/components/pr-run.js +8 -3
  72. package/dist/components/wave-plan.d.ts +14 -1
  73. package/dist/components/wave-plan.d.ts.map +1 -1
  74. package/dist/components/wave-plan.js +34 -2
  75. package/dist/gate-decision.d.ts +15 -2
  76. package/dist/gate-decision.d.ts.map +1 -1
  77. package/dist/gate-decision.js +17 -2
  78. package/dist/gate-policy.d.ts +140 -0
  79. package/dist/gate-policy.d.ts.map +1 -0
  80. package/dist/gate-policy.js +242 -0
  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/query.d.ts +68 -0
  86. package/dist/lifecycle/query.d.ts.map +1 -0
  87. package/dist/lifecycle/query.js +246 -0
  88. package/dist/lifecycle/run-ledger.d.ts +13 -0
  89. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  90. package/dist/lifecycle/run-ledger.js +16 -0
  91. package/dist/lifecycle/target-lock.d.ts +97 -0
  92. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  93. package/dist/lifecycle/target-lock.js +286 -0
  94. package/dist/op/activities/activity-contracts.d.ts +30 -0
  95. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  96. package/dist/op/activities/activity-contracts.js +26 -0
  97. package/dist/op/activities/index.d.ts +2 -0
  98. package/dist/op/activities/index.d.ts.map +1 -1
  99. package/dist/op/activities/index.js +4 -0
  100. package/dist/op/activities/pr-refs.d.ts +10 -0
  101. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  102. package/dist/op/activities/pr-refs.js +26 -0
  103. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  104. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  105. package/dist/op/activities/predict-behaviour.js +2 -21
  106. package/dist/op/activities/review-agent.d.ts +137 -0
  107. package/dist/op/activities/review-agent.d.ts.map +1 -0
  108. package/dist/op/activities/review-agent.js +266 -0
  109. package/dist/op/builders.d.ts +9 -0
  110. package/dist/op/builders.d.ts.map +1 -1
  111. package/dist/op/builders.js +11 -0
  112. package/dist/op/cron.d.ts +8 -0
  113. package/dist/op/cron.d.ts.map +1 -1
  114. package/dist/op/cron.js +51 -0
  115. package/dist/op/gate-approval.d.ts +20 -13
  116. package/dist/op/gate-approval.d.ts.map +1 -1
  117. package/dist/op/gate-approval.js +13 -18
  118. package/dist/op/gate-quorum.d.ts +131 -0
  119. package/dist/op/gate-quorum.d.ts.map +1 -0
  120. package/dist/op/gate-quorum.js +183 -0
  121. package/dist/op/gate-resume.d.ts +4 -2
  122. package/dist/op/gate-resume.d.ts.map +1 -1
  123. package/dist/op/gate-resume.js +5 -4
  124. package/dist/op/gate-review.js +1 -1
  125. package/dist/op/gate.d.ts +32 -4
  126. package/dist/op/gate.d.ts.map +1 -1
  127. package/dist/op/gate.js +40 -24
  128. package/dist/op/index.d.ts +6 -4
  129. package/dist/op/index.d.ts.map +1 -1
  130. package/dist/op/index.js +4 -3
  131. package/dist/op/local-executor.d.ts.map +1 -1
  132. package/dist/op/local-executor.js +9 -3
  133. package/dist/op/op-waves-run.d.ts +28 -43
  134. package/dist/op/op-waves-run.d.ts.map +1 -1
  135. package/dist/op/op-waves-run.js +170 -120
  136. package/dist/op/op-waves.d.ts +55 -3
  137. package/dist/op/op-waves.d.ts.map +1 -1
  138. package/dist/op/op-waves.js +63 -8
  139. package/dist/op/schedule-overdue.d.ts +57 -0
  140. package/dist/op/schedule-overdue.d.ts.map +1 -0
  141. package/dist/op/schedule-overdue.js +138 -0
  142. package/dist/op/stage-hooks.d.ts +132 -0
  143. package/dist/op/stage-hooks.d.ts.map +1 -0
  144. package/dist/op/stage-hooks.js +142 -0
  145. package/dist/override-gate.d.ts +2 -2
  146. package/dist/override-gate.d.ts.map +1 -1
  147. package/dist/override-gate.js +1 -1
  148. package/dist/pr-forge.d.ts +64 -0
  149. package/dist/pr-forge.d.ts.map +1 -1
  150. package/dist/pr-forge.js +331 -44
  151. package/dist/pr-loop.d.ts +107 -1
  152. package/dist/pr-loop.d.ts.map +1 -1
  153. package/dist/pr-loop.js +84 -3
  154. package/dist/pr-post.d.ts +72 -0
  155. package/dist/pr-post.d.ts.map +1 -0
  156. package/dist/pr-post.js +134 -0
  157. package/dist/pr-requires.d.ts +38 -0
  158. package/dist/pr-requires.d.ts.map +1 -0
  159. package/dist/pr-requires.js +48 -0
  160. package/dist/wave-blocked.d.ts +79 -0
  161. package/dist/wave-blocked.d.ts.map +1 -0
  162. package/dist/wave-blocked.js +245 -0
  163. package/dist/wave-engine.d.ts +6 -0
  164. package/dist/wave-engine.d.ts.map +1 -1
  165. package/dist/wave-engine.js +2 -0
  166. package/dist/wave-runtime.mjs +243 -243
  167. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  168. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  169. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  170. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  171. package/dist/workspace/checks/ci-workflow.js +19 -4
  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-init.d.ts.map +1 -1
  179. package/dist/workspace/ci-init.js +5 -4
  180. package/dist/workspace/ci-locks.d.ts +12 -0
  181. package/dist/workspace/ci-locks.d.ts.map +1 -0
  182. package/dist/workspace/ci-locks.js +6 -0
  183. package/dist/workspace/ci-run.d.ts +17 -1
  184. package/dist/workspace/ci-run.d.ts.map +1 -1
  185. package/dist/workspace/ci-run.js +66 -4
  186. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  187. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  188. package/dist/workspace/ci-workflow-at.js +17 -0
  189. package/dist/workspace/ci-workflow.d.ts +11 -3
  190. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  191. package/dist/workspace/ci-workflow.js +10 -2
  192. package/dist/workspace/declaration.d.ts +69 -3
  193. package/dist/workspace/declaration.d.ts.map +1 -1
  194. package/dist/workspace/declaration.js +72 -1
  195. package/dist/workspace/declaration.schema.json +149 -2
  196. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  197. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  198. package/dist/workspace/identity-gate-rule.js +1 -1
  199. package/dist/workspace/identity.d.ts +1 -1
  200. package/dist/workspace/identity.d.ts.map +1 -1
  201. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  202. package/dist/workspace/lineage-upgrade.js +8 -5
  203. package/dist/workspace/member-pipeline.d.ts +6 -0
  204. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  205. package/dist/workspace/member-pipeline.js +23 -0
  206. package/dist/workspace/pr-report.schema.json +94 -3
  207. package/dist/workspace/reason-codes.d.ts +1 -1
  208. package/dist/workspace/reason-codes.d.ts.map +1 -1
  209. package/dist/workspace/reason-codes.js +1 -1
  210. package/dist/workspace/security.d.ts.map +1 -1
  211. package/dist/workspace/security.js +4 -0
  212. package/dist/workspace/status-gates.d.ts +3 -2
  213. package/dist/workspace/status-gates.d.ts.map +1 -1
  214. package/dist/workspace/status-gates.js +8 -7
  215. package/dist/workspace/status.d.ts.map +1 -1
  216. package/dist/workspace/status.js +4 -1
  217. package/dist/workspace/trust/seal.d.ts +2 -2
  218. package/dist/workspace/trust/seal.d.ts.map +1 -1
  219. package/dist/workspace/trust/seal.js +2 -2
  220. package/package.json +1 -1
  221. package/src/__fixtures__/oidc-emulator.ts +118 -0
  222. package/src/ci-handover.test.ts +209 -0
  223. package/src/ci-handover.ts +237 -0
  224. package/src/ci-identity.test.ts +201 -0
  225. package/src/ci-identity.ts +310 -0
  226. package/src/cli/handlers/approve.ts +18 -5
  227. package/src/cli/handlers/build.ts +18 -1
  228. package/src/cli/handlers/fan-out.ts +43 -6
  229. package/src/cli/handlers/gate-resume.ts +16 -2
  230. package/src/cli/handlers/lock.test.ts +98 -0
  231. package/src/cli/handlers/lock.ts +202 -0
  232. package/src/cli/handlers/operator.test.ts +3 -1
  233. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  234. package/src/cli/handlers/pr-env.ts +462 -0
  235. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  236. package/src/cli/handlers/pr.test.ts +184 -0
  237. package/src/cli/handlers/pr.ts +605 -61
  238. package/src/cli/handlers/promote.ts +9 -0
  239. package/src/cli/handlers/query.ts +78 -0
  240. package/src/cli/handlers/run.ts +10 -0
  241. package/src/cli/main.ts +49 -4
  242. package/src/cli/parse-args.ts +34 -2
  243. package/src/cli/registry.ts +31 -2
  244. package/src/components/capability-targets.test.ts +162 -0
  245. package/src/components/capability.ts +71 -0
  246. package/src/components/cli-support.ts +14 -1
  247. package/src/components/driver.ts +54 -3
  248. package/src/components/fan-out-run.ts +58 -3
  249. package/src/components/fan-out-waves.test.ts +45 -1
  250. package/src/components/gate-plan.ts +3 -0
  251. package/src/components/pr-environment.test.ts +249 -0
  252. package/src/components/pr-environment.ts +538 -0
  253. package/src/components/pr-hooks.test.ts +118 -0
  254. package/src/components/pr-hooks.ts +157 -0
  255. package/src/components/pr-pipeline.ts +194 -21
  256. package/src/components/pr-policy.test.ts +145 -0
  257. package/src/components/pr-policy.ts +211 -0
  258. package/src/components/pr-run.ts +14 -2
  259. package/src/components/wave-plan.ts +38 -3
  260. package/src/gate-decision-bundle.test.ts +4 -3
  261. package/src/gate-decision.ts +39 -2
  262. package/src/gate-policy-bundle.test.ts +28 -0
  263. package/src/gate-policy.test.ts +162 -0
  264. package/src/gate-policy.ts +298 -0
  265. package/src/lifecycle/gate-checkpoint.ts +1 -1
  266. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  267. package/src/lifecycle/query.test.ts +91 -0
  268. package/src/lifecycle/query.ts +315 -0
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/target-lock.test.ts +195 -0
  271. package/src/lifecycle/target-lock.ts +347 -0
  272. package/src/op/activities/activity-contracts.ts +31 -0
  273. package/src/op/activities/index.ts +6 -0
  274. package/src/op/activities/pr-refs.ts +28 -0
  275. package/src/op/activities/predict-behaviour.ts +3 -23
  276. package/src/op/activities/review-agent.test.ts +240 -0
  277. package/src/op/activities/review-agent.ts +355 -0
  278. package/src/op/builders.ts +13 -0
  279. package/src/op/cron.ts +44 -0
  280. package/src/op/gate-alias.test.ts +2 -2
  281. package/src/op/gate-approval.test.ts +30 -9
  282. package/src/op/gate-approval.ts +23 -26
  283. package/src/op/gate-quorum.test.ts +139 -0
  284. package/src/op/gate-quorum.ts +248 -0
  285. package/src/op/gate-resume.ts +6 -3
  286. package/src/op/gate-review.ts +1 -1
  287. package/src/op/gate.ts +64 -23
  288. package/src/op/index.ts +13 -1
  289. package/src/op/local-executor.ts +9 -2
  290. package/src/op/op-waves-hook-targets.test.ts +126 -0
  291. package/src/op/op-waves-policy-hold.test.ts +66 -0
  292. package/src/op/op-waves-quorum.test.ts +101 -0
  293. package/src/op/op-waves-run.ts +197 -183
  294. package/src/op/op-waves.ts +83 -10
  295. package/src/op/schedule-overdue.test.ts +220 -0
  296. package/src/op/schedule-overdue.ts +161 -0
  297. package/src/op/stage-hooks.ts +251 -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 +347 -35
  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 +170 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/pr-requires.test.ts +184 -0
  309. package/src/pr-requires.ts +79 -0
  310. package/src/review-agent-bundle.test.ts +35 -0
  311. package/src/stage-hooks-bundle.test.ts +38 -0
  312. package/src/wave-blocked-bundle.test.ts +25 -0
  313. package/src/wave-blocked.test.ts +146 -0
  314. package/src/wave-blocked.ts +271 -0
  315. package/src/wave-engine.ts +8 -0
  316. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  317. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  318. package/src/workspace/checks/ci-workflow.ts +18 -4
  319. package/src/workspace/ci-branches.test.ts +62 -0
  320. package/src/workspace/ci-branches.ts +79 -0
  321. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  322. package/src/workspace/ci-cloud-identity.ts +23 -0
  323. package/src/workspace/ci-init.ts +5 -4
  324. package/src/workspace/ci-locks.ts +16 -0
  325. package/src/workspace/ci-run-index.test.ts +2 -1
  326. package/src/workspace/ci-run.test.ts +107 -8
  327. package/src/workspace/ci-run.ts +74 -5
  328. package/src/workspace/ci-workflow-at.ts +23 -0
  329. package/src/workspace/ci-workflow.ts +13 -2
  330. package/src/workspace/declaration.schema.json +149 -2
  331. package/src/workspace/declaration.test.ts +76 -0
  332. package/src/workspace/declaration.ts +140 -5
  333. package/src/workspace/identity-gate-rule.ts +1 -1
  334. package/src/workspace/identity.ts +1 -1
  335. package/src/workspace/lineage-upgrade.ts +11 -8
  336. package/src/workspace/member-pipeline.test.ts +29 -0
  337. package/src/workspace/member-pipeline.ts +30 -0
  338. package/src/workspace/pr-report.schema.json +94 -3
  339. package/src/workspace/reason-codes.ts +1 -1
  340. package/src/workspace/security.ts +4 -0
  341. package/src/workspace/status-gates.ts +9 -6
  342. package/src/workspace/status.ts +4 -1
  343. package/src/workspace/trust/gate-seal.test.ts +1 -1
  344. package/src/workspace/trust/seal.ts +2 -2
  345. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  346. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
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);
package/src/op/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, helmInstallPinned, waitForStack, waitForReady,
2
- gitlabPipeline, lifecycleSnapshot, shell, sourceArchive, releasePlan, releaseRecord, composeChangeSet, lifecyclePlanChangeSet, readChangeSetPart, releaseRollbackPlan, releaseRollbackRecord, decide, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
2
+ gitlabPipeline, lifecycleSnapshot, shell, sourceArchive, releasePlan, releaseRecord, composeChangeSet, lifecyclePlanChangeSet, readChangeSetPart, reviewAgent, releaseRollbackPlan, releaseRollbackRecord, decide, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
3
3
  k3sInstall, k3sUninstall, flociUp, flociDown,
4
4
  flociAzUp, flociAzDown, flociGcpUp, flociGcpDown, httpCheck,
5
5
  azGroupEnsure, azGroupDelete, azApply, azDelete, awsApply, awsDelete, gcpApply, gcpDelete, policyGate,
@@ -57,10 +57,13 @@ export {
57
57
  OP_WAVE_DEFAULT_GATE_NAME,
58
58
  OP_WAVE_GATE_POLICIES,
59
59
  OP_WAVE_HOOK_MOMENTS,
60
+ PR_LOOP_HOOK_MOMENTS,
60
61
  assertOpWaveHooks,
61
62
  assertOpWavesSpec,
63
+ assertPrLoopHooks,
62
64
  opWaveApplyHooksRunBy,
63
65
  opWaveGateName,
66
+ opWaveHookRuns,
64
67
  opWaveHooksAt,
65
68
  opWaveJobs,
66
69
  opWaveRecordPath,
@@ -71,6 +74,7 @@ export {
71
74
  OP_WAVE_APPLY_OUTCOMES,
72
75
  OP_WAVE_MAX_REPLANS,
73
76
  opWaveHooksAtBase,
77
+ runStageHooks,
74
78
  opWaveNeedsApproval,
75
79
  opWavePlansPath,
76
80
  opWaveRunsPath,
@@ -102,6 +106,8 @@ export type {
102
106
  OpWaveShowAtBase,
103
107
  RecordHeadPlansOptions,
104
108
  RunOpWaveApplyHooksOptions,
109
+ RunStageHooksOptions,
110
+ StageHooksRun,
105
111
  RunOpWaveOptions,
106
112
  RunOpWaveResult,
107
113
  } from "./op-waves-run";
@@ -130,6 +136,12 @@ export type {
130
136
  GateApproval, GateApprovalMode, GateApprover, GateContextValue, GatePolicyAnswer, GatePolicyDecision,
131
137
  GatePolicyEvaluator, GatePolicyRef, GatePolicyRequest, GateQuorum, ResolvedGateApproval,
132
138
  } from "./gate-approval";
139
+ export {
140
+ quorumRules, quorumRulesFor, quorumNeed, quorumProblems, quorumRuleProgress, quorumRuleLine, quorumWaiting, rolesOf, trustQuorumJudge, UNPROVEN_JUDGE,
141
+ } from "./gate-quorum";
142
+ export type {
143
+ GateQuorumRule, GateQuorumSpec, GateQuorumJudge, GateQuorumRuleProgress, QuorumJudgement, QuorumApprover, TrustQuorumInput,
144
+ } from "./gate-quorum";
133
145
  export {
134
146
  gatePlanSummary, gatePlanSummaryOfResult, changeSetOfResult, GATE_PLAN_CONTEXT_KEY, GATE_PLAN_ADDRESS_LIMIT,
135
147
  } from "./gate-plan-context";
@@ -41,6 +41,7 @@ import { resolveGateAliases } from "./gate-alias";
41
41
  import { gatePointOf } from "./gate-point";
42
42
  import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
43
43
  import type { ResolvedGateApproval } from "./gate-approval";
44
+ import { quorumRules, quorumRulesFor } from "./gate-quorum";
44
45
  import { GATE_PLAN_CONTEXT_KEY, gatePlanSummaryOfResult } from "./gate-plan-context";
45
46
  import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
46
47
  import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
@@ -732,6 +733,7 @@ function gateFn(step: GateStep): string {
732
733
  function resolveGateApproval(
733
734
  step: GateStep,
734
735
  resultsById: ReadonlyMap<string, unknown>,
736
+ env?: string,
735
737
  ): ResolvedGateApproval | undefined {
736
738
  const authored = step.approval;
737
739
  if (!authored) return undefined;
@@ -750,8 +752,13 @@ function resolveGateApproval(
750
752
  const summary = gatePlanSummaryOfResult(resultsById.get(step.plan.step));
751
753
  if (summary) context = { ...(context ?? {}), [GATE_PLAN_CONTEXT_KEY]: summary };
752
754
  }
755
+ // #4290: the rules this run's gate takes, by its environment. A rule with
756
+ // `targets` never covers a run with no environment. One rule with no
757
+ // `targets` keeps its own shape, so a standing pending fact still matches.
758
+ const single = authored.quorum !== undefined && !Array.isArray(authored.quorum) && (authored.quorum as { targets?: unknown }).targets === undefined;
759
+ const rules = authored.quorum ? quorumRulesFor(quorumRules(authored.quorum), env !== undefined ? [env] : []) : [];
753
760
  return {
754
- ...(authored.quorum ? { quorum: authored.quorum } : {}),
761
+ ...(single ? { quorum: authored.quorum } : rules.length > 0 ? { quorum: rules } : {}),
755
762
  ...(authored.policy ? { policy: authored.policy } : {}),
756
763
  mode: authored.mode ?? "log-only",
757
764
  ...(context ? { context } : {}),
@@ -779,7 +786,7 @@ async function runGateStep(
779
786
  // a run over a missing digest.
780
787
  const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
781
788
  const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
782
- const approval = resolveGateApproval(step, resultsById);
789
+ const approval = resolveGateApproval(step, resultsById, gates.env);
783
790
  // #3947: a gate that resumes stores the outputs the steps after it read.
784
791
  const resumeAt = gates.resume?.gates.get(step);
785
792
  let checkpoint: GateCheckpoint | undefined;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Hook `targets` and the `before.observe` moment (#4288), in an Op waves
3
+ * job and through `runStageHooks`.
4
+ */
5
+ import { describe, expect, test } from "vitest";
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { assertOpWaveHooks, assertPrLoopHooks, opWaveHookRuns, opWaveHooksAt, OP_WAVE_HOOK_MOMENTS, type OpWavesSpec } from "./op-waves";
10
+ import { runOpWave, runStageHooks, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
11
+ import { memoryGateLedgerPort } from "./gate";
12
+
13
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
14
+
15
+ describe("hook targets and before.observe in the spec (#4288)", () => {
16
+ test("before.observe is the first moment, and opWaveHooksAt reads it", () => {
17
+ expect(OP_WAVE_HOOK_MOMENTS[0]).toBe("before-observe");
18
+ const hooks = opWaveHooksAt({ hooks: { before: { observe: [{ command: ["init"] }] } }, waves: [{ name: "prod", runs: [] }] }, "prod");
19
+ expect(hooks["before-observe"]).toEqual([{ command: ["init"] }]);
20
+ });
21
+
22
+ test("the spec refuses after.observe, {plan} before the observe and targets that are not a list of names", () => {
23
+ expect(() => assertOpWaveHooks("w", { after: { observe: [{ command: ["x"] }] } } as never)).toThrow(/hooks.after.observe is not a moment/);
24
+ expect(() => assertOpWaveHooks("w", { before: { observe: [{ command: ["x", "{plan}"], perRun: true }] } })).toThrow(/no \{plan\} yet/);
25
+ expect(() => assertOpWaveHooks("w", { before: { plan: [{ command: ["x"], targets: [] }] } })).toThrow(/targets must be a non-empty list/);
26
+ expect(() => assertOpWaveHooks("w", { before: { plan: [{ command: ["x"], targets: ["prod/*", 3] as never }] } })).toThrow(/targets must be a non-empty list/);
27
+ expect(() => assertOpWaveHooks("w", { before: { observe: [{ command: ["x"], targets: ["prod/**"], onFailure: "approve" }] } })).not.toThrow();
28
+ });
29
+
30
+ test("a pull-request job refuses after.gate and apply.runBy", () => {
31
+ expect(() => assertPrLoopHooks("ci.hooks", { after: { gate: [{ command: ["x"] }] } })).toThrow(/after.gate runs in an Op waves job only/);
32
+ expect(() => assertPrLoopHooks("ci.hooks", { apply: { runBy: "apply" } })).toThrow(/runBy is for an Op waves spec/);
33
+ expect(() => assertPrLoopHooks("ci.hooks", { before: { observe: [{ command: ["x"] }], apply: [{ command: ["y"] }] } })).not.toThrow();
34
+ });
35
+
36
+ test("targets match a run by name or by a pattern, as a job group's runs do", () => {
37
+ const runs = [{ target: "prod/eu" }, { target: "prod/us/east" }, { target: "dev" }];
38
+ expect(opWaveHookRuns({}, runs).map((r) => r.target)).toEqual(["prod/eu", "prod/us/east", "dev"]);
39
+ expect(opWaveHookRuns({ targets: ["prod/*"] }, runs).map((r) => r.target)).toEqual(["prod/eu"]);
40
+ expect(opWaveHookRuns({ targets: ["prod/**", "dev"] }, runs).map((r) => r.target)).toEqual(["prod/eu", "prod/us/east", "dev"]);
41
+ });
42
+ });
43
+
44
+ describe("runOpWave with targets and before.observe (#4288)", () => {
45
+ const spec: OpWavesSpec = {
46
+ name: "roots",
47
+ op: "tf",
48
+ plan: ["tool", "plan", "{target}", "{plan}"],
49
+ apply: ["tool", "apply", "{target}", "{plan}"],
50
+ hooks: {
51
+ before: {
52
+ observe: [{ name: "init", command: ["hook", "init", "{target}"], perRun: true, targets: ["eu/*"] }],
53
+ plan: [{ name: "us-only", command: ["hook", "us", "{runs}"], targets: ["us/*"] }],
54
+ },
55
+ after: { plan: [{ name: "eu-check", command: ["hook", "eu-check", "{runs}"], targets: ["eu/*"] }] },
56
+ },
57
+ waves: [{ name: "prod", runs: [{ target: "eu/a" }, { target: "eu/b" }, { target: "asia/c" }], gate: "never" }],
58
+ };
59
+
60
+ function harness(fails: string[] = []) {
61
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-targets-"));
62
+ const calls: Array<{ argv: string; env?: Record<string, string> }> = [];
63
+ const exec: OpWaveExec = (argv, dir, env) => {
64
+ calls.push({ argv: argv.join(" "), ...(env ? { env } : {}) });
65
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest(argv[2]!.endsWith("a") ? "1" : "2") }));
66
+ return fails.some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
67
+ };
68
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
69
+ return { cwd, calls, exec, show, argv: () => calls.map((c) => c.argv), done: () => rmSync(cwd, { recursive: true, force: true }) };
70
+ }
71
+
72
+ test("before-observe runs ahead of before-plan, each hook only for the runs its targets match", async () => {
73
+ const h = harness();
74
+ const logs: string[] = [];
75
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: (l) => logs.push(l) });
76
+ expect(result.exitCode).toBe(0);
77
+ expect(h.argv().filter((c) => c.startsWith("hook") || c.startsWith("tool plan"))).toEqual([
78
+ "hook init eu/a",
79
+ "hook init eu/b",
80
+ "tool plan eu/a .chant/op-waves/roots/wave-1/eu_a.plan.json",
81
+ "tool plan eu/b .chant/op-waves/roots/wave-1/eu_b.plan.json",
82
+ "tool plan asia/c .chant/op-waves/roots/wave-1/asia_c.plan.json",
83
+ "hook eu-check .chant/op-waves/roots/wave-1/runs.json",
84
+ ]);
85
+ expect(h.calls[0]!.env).toMatchObject({ CHANT_HOOK: "before-observe", CHANT_TARGET: "eu/a" });
86
+ expect(logs).toContain("before-plan hook us-only: no run matches its targets (us/*), so it did not run");
87
+ expect(result.hooks.map((o) => `${o.moment} ${o.name}${o.target ? ` ${o.target}` : ""} ${o.outcome}`)).toEqual([
88
+ "before-observe init eu/a passed",
89
+ "before-observe init eu/b passed",
90
+ "after-plan eu-check passed",
91
+ ]);
92
+ h.done();
93
+ });
94
+
95
+ test("a failed before-observe hook stops the wave before anything plans", async () => {
96
+ const h = harness(["hook init eu/b"]);
97
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
98
+ expect(result.exitCode).toBe(1);
99
+ expect(h.argv().some((c) => c.startsWith("tool"))).toBe(false);
100
+ h.done();
101
+ });
102
+ });
103
+
104
+ describe("runStageHooks (#4288)", () => {
105
+ test("runs one moment's hooks for the runs given, with targets, and counts an approve failure as a hold", () => {
106
+ const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-"));
107
+ const calls: string[] = [];
108
+ const exec: OpWaveExec = (argv) => {
109
+ calls.push(argv.join(" "));
110
+ return argv[1] === "gate" ? 1 : 0;
111
+ };
112
+ const ran = runStageHooks(
113
+ "before-plan",
114
+ [
115
+ { name: "each", command: ["hook", "each", "{target}", "{op}"], perRun: true, targets: ["api"] },
116
+ { name: "gate", command: ["hook", "gate", "{wave}"], onFailure: "approve" },
117
+ ],
118
+ { name: "pr-7", op: "pr-7", wave: "prod", runs: [{ target: "api", op: "pr-7" }, { target: "web", op: "pr-7" }], runsFile: "runs.json", holdFile: "hold.json", cwd, exec, log: () => {} },
119
+ );
120
+ expect(calls).toEqual(["hook each api pr-7", "hook gate prod"]);
121
+ expect(ran.failed).toBeUndefined();
122
+ expect(ran.holds).toEqual(["before-plan hook gate"]);
123
+ expect(ran.outcomes.map((o) => o.outcome)).toEqual(["passed", "held"]);
124
+ rmSync(cwd, { recursive: true, force: true });
125
+ });
126
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A plan the product's policy holds (#4294): the wave waits for a person's
3
+ * approval of its set digest whatever its gate policy says, and applies
4
+ * once one stands.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import type { OpWavesSpec } from "./op-waves";
12
+ import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
13
+ import { memoryGateLedgerPort } from "./gate";
14
+ import { waveSetDigest } from "../gated-waves";
15
+
16
+ const spec: OpWavesSpec = {
17
+ name: "roots",
18
+ op: "tf",
19
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
20
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
21
+ waves: [{ name: "dev", runs: [{ target: "vpc" }], gate: "never" }],
22
+ };
23
+
24
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
25
+
26
+ function harness(plan: Record<string, unknown>) {
27
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-hold-"));
28
+ const calls: string[] = [];
29
+ const exec: OpWaveExec = (argv, dir) => {
30
+ calls.push(argv.join(" "));
31
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify(plan));
32
+ return 0;
33
+ };
34
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
35
+ return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
36
+ }
37
+
38
+ describe("a policy hold on an Op waves plan (#4294)", () => {
39
+ test("a held run makes a gate: never wave wait, and an approval of its digest lets it apply", async () => {
40
+ const h = harness({ planDigest: digest("1"), held: { rules: ["main.hold_iam", "main.hold_iam"], reason: "an IAM change" } });
41
+ const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
42
+ expect(waiting.exitCode).toBe(3);
43
+ expect(waiting.decision).toMatchObject({ status: "waiting", policy: "never", heldBy: ["policy on vpc (main.hold_iam): an IAM change"] });
44
+ expect(waiting.decision!.members[0]).toMatchObject({ member: "vpc", held: { rules: ["main.hold_iam"] } });
45
+ expect(h.calls.some((c) => c.startsWith("tool apply"))).toBe(false);
46
+
47
+ const set = waveSetDigest([{ member: "vpc", planDigest: digest("1") }]);
48
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "roots", gate: "roots-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
49
+ const approved = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
50
+ expect(approved).toMatchObject({ exitCode: 0, applied: ["vpc"], decision: { status: "approved", approvedBy: "alice" } });
51
+ h.done();
52
+ });
53
+
54
+ test("a run with no hold under gate: never needs no approval", async () => {
55
+ const h = harness({ planDigest: digest("2") });
56
+ const free = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
57
+ expect(free).toMatchObject({ exitCode: 0, applied: ["vpc"], decision: { status: "not-required" } });
58
+ h.done();
59
+ });
60
+
61
+ test("a hold that names no rule is refused", async () => {
62
+ const h = harness({ planDigest: digest("3"), held: { rules: [] } });
63
+ await expect(runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/a hold is \{ "rules"/);
64
+ h.done();
65
+ });
66
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { assertOpWavesSpec, type OpWavesSpec } from "./op-waves";
6
+ import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
7
+ import { memoryGateLedgerPort } from "./gate";
8
+ import { waveSetDigest } from "../gated-waves";
9
+ import type { TrustPolicy } from "../workspace/trust/policy";
10
+ import type { MergedReview, ReviewSource } from "./gate-review";
11
+
12
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
13
+
14
+ const spec: OpWavesSpec = {
15
+ name: "migrations",
16
+ op: "migrate",
17
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
18
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
19
+ waves: [{ name: "prod", runs: [{ target: "prod-eu" }, { target: "prod-us" }] }],
20
+ };
21
+ const set = waveSetDigest([
22
+ { member: "prod-eu", planDigest: digest("e") },
23
+ { member: "prod-us", planDigest: digest("u") },
24
+ ]);
25
+
26
+ function harness(atBase: OpWavesSpec) {
27
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-quorum-"));
28
+ const calls: string[] = [];
29
+ const exec: OpWaveExec = (argv, dir) => {
30
+ calls.push(argv.join(" "));
31
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: digest(argv[2] === "prod-eu" ? "e" : "u") }));
32
+ return 0;
33
+ };
34
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(atBase) });
35
+ return { cwd, exec, show, applies: () => calls.filter((c) => c.startsWith("tool apply")).length, done: () => rmSync(cwd, { recursive: true, force: true }) };
36
+ }
37
+
38
+ const trust = (roles: Record<string, string[]>): (() => TrustPolicy) => () =>
39
+ ({ base: "b".repeat(40), signersPath: ".chant/allowed_signers", active: false, signers: [], excluded: [], roles, adopted: [], runners: [], excludedRunners: [], admitted: {}, problems: [] }) as TrustPolicy;
40
+
41
+ const reviews = (merged: MergedReview | null): ReviewSource => ({ kind: "github", mergedReview: async () => merged });
42
+ const approval = (who: string, at: string) => ({ version: 1 as const, op: "migrations", gate: "migrations-wave-1", resolvedBy: who, timestamp: at, planDigest: set });
43
+
44
+ describe("Op waves approval rules (#4290)", () => {
45
+ test("the spec checks approvals.quorum, and refuses it beside a decision point", () => {
46
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: [{ targets: ["prod-*"], count: 2, roles: ["platform"], notAuthor: true }] } })).not.toThrow();
47
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: [{ count: 0 }] } })).toThrow(/approvals.quorum\[0\].count/);
48
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: { count: 1 } } } as unknown as OpWavesSpec)).toThrow(/must be a list of rules/);
49
+ expect(() => assertOpWavesSpec({ ...spec, point: "ship", approvals: { quorum: [{ count: 1 }] } })).toThrow(/both set/);
50
+ });
51
+
52
+ test("a rule at base whose glob matches a run holds the wave until two people approve", async () => {
53
+ const atBase: OpWavesSpec = { ...spec, approvals: { quorum: [{ targets: ["prod-*"], count: 2 }, { targets: ["dev"], count: 5 }] } };
54
+ const h = harness(atBase);
55
+ const gates = memoryGateLedgerPort({ resolutions: [approval("alice", "2026-10-22T01:00:00Z")] });
56
+ const logs: string[] = [];
57
+ // The change drops the rule; the one at base holds.
58
+ const one = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-22T02:00:00Z", log: (l) => logs.push(l) });
59
+ expect(one.exitCode).toBe(3);
60
+ expect(one.decision?.quorum).toEqual([{ targets: ["prod-*"], count: 2 }]);
61
+ expect(one.decision?.quorumProgress?.[0]).toMatchObject({ approvers: ["alice"], met: false });
62
+ expect(logs.some((l) => /the ones at base hold/.test(l))).toBe(true);
63
+ // Approvals made while the wave waits count, each person once.
64
+ const two = memoryGateLedgerPort({ resolutions: [approval("alice", "2026-10-22T02:30:00Z"), approval("alice", "2026-10-22T02:45:00Z"), approval("bob", "2026-10-22T03:00:00Z")], pending: gates.appended });
65
+ const done = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: two, now: "2026-10-22T04:00:00Z" });
66
+ expect(done.exitCode).toBe(0);
67
+ expect(done.decision?.approvedBy).toBe("alice, bob");
68
+ expect(h.applies()).toBe(2);
69
+ h.done();
70
+ });
71
+
72
+ test("a claimed role never counts; a role from trust.json at base counts for a reviewer of the merged pull request", async () => {
73
+ const atBase: OpWavesSpec = { ...spec, waves: [{ ...spec.waves[0]!, approval: "pr-review" }], approvals: { quorum: [{ count: 1, roles: ["platform"], notAuthor: true }] } };
74
+ const h = harness(atBase);
75
+ const claimed = memoryGateLedgerPort({ resolutions: [{ ...approval("github:carol", "2026-10-22T01:00:00Z"), approver: { kind: "human", roles: ["platform"] } }] });
76
+ const merged: MergedReview = { pr: 7, head: "h".repeat(40), author: "github:dana", approvers: ["github:alice"], refused: [] };
77
+ const headPlans = async () => ({ version: 1 as const, name: "migrations", head: merged.head, timestamp: "2026-10-22T00:00:00Z", waves: [{ wave: 1, name: "prod", digest: set }] });
78
+ const base = { spec: atBase, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, head: "m".repeat(40), headPlans, now: "2026-10-22T02:00:00Z" };
79
+ // carol claims the role and is not proven; nobody reviewed.
80
+ const waiting = await runOpWave({ ...base, gates: claimed, reviews: reviews({ ...merged, approvers: [] }), trust: trust({ platform: ["github:alice", "github:carol"] }) });
81
+ expect(waiting.exitCode).toBe(3);
82
+ // alice reviewed and holds the role at base; dana wrote the change.
83
+ const gates = memoryGateLedgerPort();
84
+ const passed = await runOpWave({ ...base, gates, reviews: reviews(merged), trust: trust({ platform: ["github:alice"] }) });
85
+ expect(passed.exitCode).toBe(0);
86
+ expect(passed.decision).toMatchObject({ via: "pr-review", approvedBy: "github:alice" });
87
+ expect(gates.resolved.map((r) => r.resolvedBy)).toEqual(["github:alice"]);
88
+ // The author reviewing their own change never meets notAuthor.
89
+ const own = await runOpWave({ ...base, gates: memoryGateLedgerPort(), reviews: reviews({ ...merged, author: "github:alice" }), trust: trust({ platform: ["github:alice"] }) });
90
+ expect(own.exitCode).toBe(3);
91
+ h.done();
92
+ });
93
+
94
+ test("rules at base that cannot be read fail the wave", async () => {
95
+ const h = harness({ ...spec, approvals: { quorum: [{ count: 0 }] } });
96
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
97
+ expect(result.exitCode).toBe(1);
98
+ expect(h.applies()).toBe(0);
99
+ h.done();
100
+ });
101
+ });