@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
@@ -19,6 +19,8 @@
19
19
  import { RETENTION_KINDS, type RetentionOverride } from "../lifecycle/retention-policy";
20
20
  import { parseReportUrl } from "../pr-report-url";
21
21
  import schema from "./declaration.schema.json" with { type: "json" };
22
+ import { routeConflicts } from "./ci-branches";
23
+ import { cloudIdentityProblems, CLOUDS, type CloudIdentityDeclaration } from "../ci-identity";
22
24
  import { expandGlob } from "./glob";
23
25
  import { EXAMPLES_KIND, holdsChantProject } from "./kinds";
24
26
  import { parseJsonText, pointerToken, type TextLocation } from "./jsonc";
@@ -27,6 +29,8 @@ import type { SecurityGroup } from "./security";
27
29
  import { joinPath, type WorkspaceTree } from "./tree";
28
30
  import { loadAjv2020 } from "./ajv2020";
29
31
  import { readChantPackageVersion } from "../package-version";
32
+ import { assertPrLoopHooks, type OpWaveHooks } from "../op/op-waves";
33
+ import { assertGatePolicySettings, type GatePolicySettings } from "../gate-policy";
30
34
 
31
35
  export const DECLARATION_SCHEMA_ID = schema.$id;
32
36
  export const DECLARATION_FILES = ["chant.workspace.json", "chant.workspace.jsonc"] as const;
@@ -259,11 +263,22 @@ export type CiSource = "checks" | "runs";
259
263
  */
260
264
  export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge";
261
265
 
266
+ /**
267
+ * When a pull request takes its target locks (#4293, ../lifecycle/target-lock.ts).
268
+ * `apply`: its apply takes them, and `chant lock` does by hand. `plan`: its
269
+ * plan takes them too, through `chant lock --stage plan` in a job that runs
270
+ * the target branch's code. Under either, an apply refuses a target another
271
+ * open pull request holds.
272
+ */
273
+ export type CiLocks = "apply" | "plan";
274
+
262
275
  /** The declaration's `ci` block (#3573). */
263
276
  export interface CiDeclaration {
264
277
  green: CiGreen | null;
265
278
  /** `ci.workflow` (#4168 step 6): the workflow every member follows unless it names its own; absent when the declaration sets none. */
266
279
  workflow?: CiWorkflow;
280
+ /** `ci.locks` (#4293): when a pull request takes its target locks; absent when the declaration sets none, and then nothing is locked. */
281
+ locks?: CiLocks;
267
282
  /**
268
283
  * `ci.reportUrl` (#4160): where the pull-request jobs copy their reports
269
284
  * and `chant ci run` keeps the run index, `<reportUrl>/runs/index.json`.
@@ -272,8 +287,56 @@ export interface CiDeclaration {
272
287
  * the declaration sets none.
273
288
  */
274
289
  reportUrl: string | null;
290
+ /**
291
+ * `ci.hooks` (#4288): the stage hooks the pull-request jobs run, in the
292
+ * shape an Op waves spec's `hooks` takes. `chant components pr-plan`,
293
+ * `pr-record` and `pr-apply` read them from the declaration at base.
294
+ * Absent when the declaration sets none.
295
+ */
296
+ hooks?: OpWaveHooks;
297
+ /**
298
+ * `ci.branches` (#4292): branch name to member-name globs. A member a glob
299
+ * matches applies from that branch alone; the others apply from every
300
+ * branch the map does not name (./ci-branches.ts). Absent when unset.
301
+ */
302
+ branches?: Record<string, string[]>;
303
+ /**
304
+ * `ci.labels` (#4292): pull request label to member-name globs. A labelled
305
+ * pull request plans only the members its labels match. Never a gate.
306
+ * Absent when unset.
307
+ */
308
+ labels?: Record<string, string[]>;
309
+ /**
310
+ * `ci.policy` (#4294): the policy engine the pull-request jobs run over
311
+ * the change set, read with the policy from the declaration at base.
312
+ * Absent when the declaration sets none.
313
+ */
314
+ policy?: GatePolicySettings;
315
+ /**
316
+ * `ci.identity` (#4296): `cloud` gives each cloud's plan and apply
317
+ * identities by environment and member globs (../ci-identity.ts).
318
+ * Absent when unset.
319
+ */
320
+ identity?: { cloud: CloudIdentityDeclaration };
321
+ /**
322
+ * `ci.prEnvironment` (#4298): an environment per open pull request,
323
+ * derived from `template`, torn down on close or after `ttl`
324
+ * (../components/pr-environment.ts). Absent when unset.
325
+ */
326
+ prEnvironment?: CiPrEnvironment;
275
327
  }
276
328
 
329
+ /** The `ci.prEnvironment` block (#4298), with its default TTL. */
330
+ export interface CiPrEnvironment {
331
+ /** The declared environment `pr-<n>` takes its endpoint from. */
332
+ template: string;
333
+ /** How long the environment lives after its last apply: `72h` when absent. */
334
+ ttl: string;
335
+ }
336
+
337
+ /** The default `ci.prEnvironment.ttl`. */
338
+ export const DEFAULT_PR_ENVIRONMENT_TTL = "72h";
339
+
277
340
  export interface MemberRole {
278
341
  name: string;
279
342
  /** Relative to the member's directory, or null for the whole member. */
@@ -592,6 +655,8 @@ export interface Member {
592
655
  security: SecurityGroup[];
593
656
  /** The member's own `ci.workflow` (#4168 step 6), which wins over the top level's; null when it names none. */
594
657
  ciWorkflow: CiWorkflow | null;
658
+ /** The member's own `ci.hooks` (#4288), run after the top level's; null when it names none. */
659
+ ciHooks: OpWaveHooks | null;
595
660
  suppress: Suppression[];
596
661
  /** The entry's JSON Pointer in the file, for messages. */
597
662
  pointer: string;
@@ -889,6 +954,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
889
954
  travel: e.travel === true,
890
955
  security: [...((e.security as SecurityGroup[] | undefined) ?? [])],
891
956
  ciWorkflow: ((e.ci as { workflow?: CiWorkflow } | undefined)?.workflow) ?? null,
957
+ ciHooks: ciHooksOf((e.ci as { hooks?: unknown } | undefined)?.hooks, `${pointer}/ci/hooks`, at),
892
958
  suppress,
893
959
  pointer,
894
960
  };
@@ -1013,6 +1079,15 @@ export function parseDeclaration(text: string, file: string, reader: string = re
1013
1079
  const tierSession = factoryTierSessionProblem(members, agents);
1014
1080
  if (tierSession) throw new WorkspaceReadError("declaration-invalid", tierSession.message, at(tierSession.pointer));
1015
1081
  const ci = ciOf(obj.ci, at);
1082
+ // #4292: a member applies from one branch, so two branches may not both route it.
1083
+ const [conflict] = routeConflicts(ci?.branches, members.map((m) => m.name));
1084
+ if (conflict) {
1085
+ throw new WorkspaceReadError(
1086
+ "declaration-invalid",
1087
+ `ci.branches routes member ${conflict.member} to both ${conflict.branches.join(" and ")}; a member applies from one branch`,
1088
+ at(`/ci/branches/${pointerToken(conflict.branches[1]!)}`),
1089
+ );
1090
+ }
1016
1091
 
1017
1092
  const pins = ((obj.pins as Record<string, string>[] | undefined) ?? []).map((p) => ({
1018
1093
  package: p.package ?? null,
@@ -1134,10 +1209,67 @@ function retentionOf(raw: unknown): RetentionOverride | null {
1134
1209
  return out;
1135
1210
  }
1136
1211
 
1212
+ /** A `ci.hooks` block (#4288), refused when a pull-request job could not run it. Null when absent. */
1213
+ function ciHooksOf(raw: unknown, pointer: string, at: (pointer: string, key?: boolean) => ErrorLocation): OpWaveHooks | null {
1214
+ if (raw === undefined) return null;
1215
+ try {
1216
+ assertPrLoopHooks(pointer.slice(1).replace(/\//g, "."), raw as OpWaveHooks);
1217
+ } catch (err) {
1218
+ throw new WorkspaceReadError("declaration-invalid", err instanceof Error ? err.message : String(err), at(pointer));
1219
+ }
1220
+ return structuredClone(raw as OpWaveHooks);
1221
+ }
1222
+
1223
+ /** A `ci.branches` or `ci.labels` map, copied without its `x-` keys. */
1224
+ function globMap(raw: Record<string, string[]>): Record<string, string[]> {
1225
+ return Object.fromEntries(Object.entries(raw).filter(([k]) => !k.startsWith("x-")).map(([k, v]) => [k, [...v]]));
1226
+ }
1227
+
1228
+ /** A `ci.policy` block (#4294), refused when the pull-request jobs could not run it. Null when absent. */
1229
+ function ciPolicyOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): GatePolicySettings | null {
1230
+ if (raw === undefined) return null;
1231
+ try {
1232
+ assertGatePolicySettings("ci.policy", raw);
1233
+ } catch (err) {
1234
+ throw new WorkspaceReadError("declaration-invalid", err instanceof Error ? err.message : String(err), at("/ci/policy"));
1235
+ }
1236
+ return structuredClone(raw);
1237
+ }
1238
+
1239
+ /**
1240
+ * `ci.identity.cloud` (#4296), copied without its `x-` keys. A rule that
1241
+ * plans as an identity some rule applies as is refused: the plan runs the
1242
+ * pull request's code.
1243
+ */
1244
+ function cloudIdentityOf(raw: CloudIdentityDeclaration, at: (pointer: string, key?: boolean) => ErrorLocation): CloudIdentityDeclaration {
1245
+ const out: CloudIdentityDeclaration = {};
1246
+ for (const cloud of CLOUDS) {
1247
+ const c = raw[cloud];
1248
+ if (c === undefined) continue;
1249
+ const kept = Object.fromEntries(Object.entries(c).filter(([k]) => !k.startsWith("x-")));
1250
+ const rules = c.rules.map((r) => Object.fromEntries(Object.entries(r).filter(([k]) => !k.startsWith("x-")).map(([k, v]) => [k, Array.isArray(v) ? [...v] : v])));
1251
+ (out as Record<string, unknown>)[cloud] = { ...kept, rules };
1252
+ }
1253
+ const [problem] = cloudIdentityProblems(out);
1254
+ if (problem) throw new WorkspaceReadError("declaration-invalid", problem.message, at(problem.pointer));
1255
+ return out;
1256
+ }
1257
+
1137
1258
  function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): CiDeclaration | null {
1138
1259
  if (raw === undefined) return null;
1139
- const block = raw as { reportUrl?: string; workflow?: CiWorkflow; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
1140
- const workflow = block.workflow !== undefined ? { workflow: block.workflow } : {};
1260
+ const block = raw as { reportUrl?: string; workflow?: CiWorkflow; hooks?: unknown; identity?: { cloud?: CloudIdentityDeclaration }; branches?: Record<string, string[]>; labels?: Record<string, string[]>; locks?: CiLocks; policy?: unknown; prEnvironment?: { template: string; ttl?: string }; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
1261
+ const hooks = ciHooksOf(block.hooks, "/ci/hooks", at);
1262
+ const policy = ciPolicyOf(block.policy, at);
1263
+ const workflow = {
1264
+ ...(block.workflow !== undefined ? { workflow: block.workflow } : {}),
1265
+ ...(hooks ? { hooks } : {}),
1266
+ ...(block.branches !== undefined ? { branches: globMap(block.branches) } : {}),
1267
+ ...(block.labels !== undefined ? { labels: globMap(block.labels) } : {}),
1268
+ ...(block.locks !== undefined ? { locks: block.locks } : {}),
1269
+ ...(policy ? { policy } : {}),
1270
+ ...(block.identity?.cloud !== undefined ? { identity: { cloud: cloudIdentityOf(block.identity.cloud, at) } } : {}),
1271
+ ...(block.prEnvironment !== undefined ? { prEnvironment: { template: block.prEnvironment.template, ttl: block.prEnvironment.ttl ?? DEFAULT_PR_ENVIRONMENT_TTL } } : {}),
1272
+ };
1141
1273
  let reportUrl: string | null = null;
1142
1274
  if (block.reportUrl !== undefined) {
1143
1275
  // #4160: refused here as pr-plan and ci run refuse the flag, so a reader never gets a URL no job can write to.
@@ -14,7 +14,7 @@
14
14
  * else: no zod, no ajv, no package loading, so it bundles small.
15
15
  */
16
16
 
17
- import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshKeyFingerprint, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./trust/gate-seal";
17
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshKeyFingerprint, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "../lifecycle/gate-seal";
18
18
  import type { Signer } from "./trust/trust-files";
19
19
 
20
20
  /** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
@@ -31,7 +31,7 @@ import { classesOf, coreClassRegistry, roleGrants, type ClassRegistry } from "./
31
31
  import type { ReasonCode } from "./reason-codes";
32
32
  import { normalisePrincipal } from "./records";
33
33
  import type { TrustPolicy } from "./trust/policy";
34
- import type { SealedGateApproval } from "./trust/gate-seal";
34
+ import type { SealedGateApproval } from "../lifecycle/gate-seal";
35
35
  import { scopeSource } from "./write-scope";
36
36
 
37
37
  /** The forges a forge identity can name. */
@@ -42,6 +42,7 @@ import { tmpdir } from "node:os";
42
42
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
43
43
  import { computePlanDigest } from "../lifecycle/plan-digest";
44
44
  import type { ResolvedGateApproval } from "../op/gate-approval";
45
+ import { quorumRules, type GateQuorumSpec } from "../op/gate-quorum";
45
46
  import { checkLineage, findingKey, type CheckFinding } from "./lineage-check";
46
47
  import { dirLabel, git, parseDirSpec, parseTemplateSource, portableUrl, readTemplateDir, readTemplateTree, recordedDirPath } from "./lineage-init";
47
48
  import {
@@ -589,11 +590,13 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
589
590
  for (const { config, filePath } of ops.values()) {
590
591
  const rel = relative(repo, filePath).split(sep).join("/");
591
592
  if (!opFiles.has(rel)) continue;
593
+ // #4290: a quorum may be a list of rules; the strictest count is asked for.
592
594
  for (const approval of gateApprovals(config)) {
593
- const need = approval.quorum?.count ?? 1;
594
- if (need > count) {
595
- count = need;
596
- roles = approval.quorum?.roles;
595
+ for (const rule of quorumRules(approval.quorum)) {
596
+ if (rule.count > count) {
597
+ count = rule.count;
598
+ roles = rule.roles;
599
+ }
597
600
  }
598
601
  }
599
602
  }
@@ -602,10 +605,10 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
602
605
  return { mode: "log-only", quorum: { count, ...(roles && roles.length > 0 ? { roles } : {}) } };
603
606
  }
604
607
 
605
- type StepLike = { kind: string; approval?: { quorum?: { count: number; roles?: string[] } }; steps?: StepLike[] };
608
+ type StepLike = { kind: string; approval?: { quorum?: GateQuorumSpec }; steps?: StepLike[] };
606
609
 
607
- function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ quorum?: { count: number; roles?: string[] } }> {
608
- const out: Array<{ quorum?: { count: number; roles?: string[] } }> = [];
610
+ function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ quorum?: GateQuorumSpec }> {
611
+ const out: Array<{ quorum?: GateQuorumSpec }> = [];
609
612
  const visit = (step: StepLike): void => {
610
613
  if (step.kind === "gate") out.push(step.approval ?? {});
611
614
  if (step.kind === "effect") (step.steps ?? []).forEach(visit);
@@ -1042,7 +1045,7 @@ export function describeStaged(staged: StagedUpgrade): string[] {
1042
1045
  }
1043
1046
  for (const s of staged.manualSteps) lines.push(` manual step: ${at(s.path)} (${s.reason})`);
1044
1047
  if (staged.governance) {
1045
- const q = staged.governance.approval.quorum!;
1048
+ const q = quorumRules(staged.governance.approval.quorum)[0]!;
1046
1049
  lines.push(
1047
1050
  ` governance: ${staged.governance.paths.join(", ")}; needs ${q.count} human approval(s)${q.roles ? ` with a role in ${q.roles.join(", ")}` : ""}, from the rules at ${staged.governance.rules.slice(0, 12)}`,
1048
1051
  );
@@ -178,3 +178,32 @@ describe("the generated-file record", () => {
178
178
  expect(readGeneratedRecord(dir).files.map((f) => f.path)).toEqual(["a.yml", "b.yml"]);
179
179
  });
180
180
  });
181
+
182
+ describe("ci.branches routes a member's pull-request pipeline (#4292)", () => {
183
+ const routed = () => {
184
+ rmSync(repo, { recursive: true, force: true });
185
+ repo = twoMemberWorkspace({ extra: { ci: { branches: { release: ["api"] } } } });
186
+ };
187
+ const plan = (dir: string, branch?: string) =>
188
+ planMemberComponentPipeline({ projectDir: dir, found: findWorkspaceRoot(dir)!, lexicon: "github", env: "staging", prLoop: branch ? { branch } : {} })!;
189
+
190
+ test("a routed member's pipeline targets its branch, with or without --branch naming it", () => {
191
+ routed();
192
+ const dir = join(repo, "services/api");
193
+ expect(plan(dir).branch).toBe("release");
194
+ expect(plan(dir, "release").branch).toBe("release");
195
+ expect(plan(dir).command).toBe("chant build --components --generate github --env staging --pr-loop");
196
+ });
197
+
198
+ test("--branch may not move a routed member, nor put another member on a routed branch", () => {
199
+ routed();
200
+ expect(() => plan(join(repo, "services/api"), "main")).toThrow("--branch main: ci.branches routes member api to release, so its pull-request pipeline targets release");
201
+ expect(() => plan(join(repo, "apps/web"), "release")).toThrow("--branch release: ci.branches applies only the members its globs match from release, and member web is not one");
202
+ });
203
+
204
+ test("an unrouted member keeps --branch, or the generator's default", () => {
205
+ routed();
206
+ expect(plan(join(repo, "apps/web")).branch).toBeUndefined();
207
+ expect(plan(join(repo, "apps/web"), "trunk").branch).toBe("trunk");
208
+ });
209
+ });
@@ -26,6 +26,7 @@ import { GENERATED_MARKER } from "../discovery/files";
26
26
  import type { PipelineMember } from "../lexicon";
27
27
  import type { WorkspaceRootSearch } from "../project-root";
28
28
  import { ownerOf, readDeclaration, resolveGroups, rootExclusions, WorkspaceReadError, type Declaration, type Member } from "./declaration";
29
+ import { routedBranch } from "./ci-branches";
29
30
  import { gitTop, workingTree } from "./tree";
30
31
 
31
32
  /**
@@ -196,6 +197,12 @@ export interface MemberComponentPlan {
196
197
  /** The command that regenerates it, run in the member's directory. */
197
198
  command: string;
198
199
  env: string;
200
+ /**
201
+ * With `--pr-loop`, the branch the pipeline targets: `--branch`, else the
202
+ * branch `ci.branches` routes the member to (#4292). Undefined for the
203
+ * generator's default.
204
+ */
205
+ branch?: string;
199
206
  }
200
207
 
201
208
  export interface ComponentPlanInput {
@@ -234,6 +241,7 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
234
241
  const ctx = resolveMemberContext(input.projectDir, input.found);
235
242
  if (!ctx) return undefined;
236
243
  const env = input.env ?? DEFAULT_ENV;
244
+ const branch = input.prLoop ? prLoopBranch(ctx, input.prLoop.branch) : undefined;
237
245
  let target: string;
238
246
  if (input.output) {
239
247
  target = resolve(input.output);
@@ -273,9 +281,31 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
273
281
  target,
274
282
  command: parts.map(shellArg).join(" "),
275
283
  env,
284
+ ...(branch !== undefined ? { branch } : {}),
276
285
  };
277
286
  }
278
287
 
288
+ /**
289
+ * The branch a member's pull-request pipeline targets (#4292): the one
290
+ * `ci.branches` routes it to, which `--branch` may repeat but not change.
291
+ * Without a route, `--branch` as given, which may not name a routed branch
292
+ * whose globs leave the member out.
293
+ */
294
+ function prLoopBranch(ctx: MemberContext, flag: string | undefined): string | undefined {
295
+ const branches = ctx.declaration.ci?.branches;
296
+ const own = routedBranch(branches, ctx.member.name);
297
+ if (own !== null) {
298
+ if (flag !== undefined && flag !== own) {
299
+ throw new Error(`--branch ${flag}: ci.branches routes member ${ctx.member.name} to ${own}, so its pull-request pipeline targets ${own}`);
300
+ }
301
+ return own;
302
+ }
303
+ if (flag !== undefined && Object.prototype.hasOwnProperty.call(branches ?? {}, flag)) {
304
+ throw new Error(`--branch ${flag}: ci.branches applies only the members its globs match from ${flag}, and member ${ctx.member.name} is not one`);
305
+ }
306
+ return flag;
307
+ }
308
+
279
309
  /** Write a member's pipeline with its header and record it as the member's generated file. */
280
310
  export function writeMemberComponentPipeline(plan: MemberComponentPlan, yaml: string): void {
281
311
  mkdirSync(dirname(plan.target), { recursive: true });
@@ -37,8 +37,8 @@
37
37
  "description": "planned: every member planned (plan stage). recorded (#4168, chant 0.124.33 and newer): every member planned on the merged commit and the merge record was written (record stage); approval says whether the plan is the approved one, and nothing applied. plan-failed: a member failed to plan, so nothing can apply. nothing: no component deploys what the change touches. applied: every member applied. failed: a member failed to apply and its dependents did not run. wave-applied (#4003, chant 0.124.32 and newer): with --wave-gate and --wave, the wave applied and a later wave is still to run. refused: nothing applied, see refusal. waiting (#3999): the approved plans applied except members held back after an upstream applied, which wait on an approval of the plan the last of rounds made."
38
38
  },
39
39
  "refusal": {
40
- "enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave"],
41
- "description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which."
40
+ "enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave", "locked", "policy-denied", "policy-error"],
41
+ "description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which. locked (#4293, chant 0.124.41 and newer): under ci.locks, another open pull request holds a lock on a target the apply reaches, and nothing applied. With ci.policy (#4294, chant 0.124.41 and newer): policy-denied, the policy at base denied the plan and no override counts; policy-error, the policy could not be run or gave no readable answer."
42
42
  },
43
43
  "message": { "type": "string", "description": "The refusal or failure as printed." },
44
44
  "resumed": {
@@ -77,6 +77,21 @@
77
77
  }
78
78
  }
79
79
  },
80
+ "overdue": {
81
+ "type": "array",
82
+ "description": "Added within contract version 1 by #4289, written by chant 0.124.41 and newer. Plan stage: the scheduled watch and reconcile Ops whose cron has fired at least twice since their job last ran, or since their pipeline file was added when no run is on record. Absent when none is overdue or the forge was not asked.",
83
+ "items": {
84
+ "type": "object",
85
+ "required": ["op", "cron", "since", "message"],
86
+ "properties": {
87
+ "op": { "type": "string" },
88
+ "cron": { "type": "string" },
89
+ "since": { "type": "string", "description": "The job's last run, or the pipeline file's first commit when neverRan, ISO 8601." },
90
+ "neverRan": { "type": "boolean" },
91
+ "message": { "type": "string", "description": "The warning as the plan note prints it." }
92
+ }
93
+ }
94
+ },
80
95
  "plannedOutputs": {
81
96
  "type": "object",
82
97
  "description": "Added within contract version 1 by #3999, written by chant 0.124.32 and newer. Per dependency in the change that a member was planned against, the names of the outputs its plan knows, sorted. An output a member reads that is not listed is known only after apply, and the member lists it under unknownInputs. Absent when no member read one.",
@@ -175,6 +190,62 @@
175
190
  "changeSet": {
176
191
  "type": "object",
177
192
  "description": "The change-set document over every member (change-set.schema.json). Its digest equals digest."
193
+ },
194
+ "hooks": {
195
+ "type": "array",
196
+ "description": "Added within contract version 1 by #4288, written by chant 0.124.41 and newer. The stage hooks the job ran, in order, from ci.hooks in the workspace declaration at base. Absent when it ran none.",
197
+ "items": {
198
+ "type": "object",
199
+ "required": ["moment", "name", "exit", "outcome"],
200
+ "additionalProperties": false,
201
+ "properties": {
202
+ "moment": { "enum": ["before-observe", "before-plan", "after-plan", "before-apply", "after-apply"] },
203
+ "name": { "type": "string" },
204
+ "target": { "type": "string", "description": "The component, for a perRun hook." },
205
+ "exit": { "type": "integer" },
206
+ "outcome": { "enum": ["passed", "failed", "held"], "description": "held: an onFailure approve hook failed, which the pull request's gate answers." }
207
+ }
208
+ }
209
+ },
210
+ "policy": {
211
+ "type": "object",
212
+ "description": "Added within contract version 1 by #4294, written by chant 0.124.41 and newer. What ci.policy in the workspace declaration at base said about the change set. Absent when the base sets no policy, or the stage reached no plan to judge.",
213
+ "required": ["result", "engine", "base", "denials", "holds", "warnings"],
214
+ "additionalProperties": false,
215
+ "properties": {
216
+ "result": { "enum": ["passed", "denied", "held", "error"], "description": "denied wins over held. error: the policy did not run or gave no readable answer, and nothing applies." },
217
+ "engine": { "enum": ["conftest", "opa"], "description": "The result shape read." },
218
+ "base": { "type": "string", "description": "The commit the settings and the policy were read at." },
219
+ "denials": { "type": "array", "items": { "$ref": "#/$defs/policyFinding" } },
220
+ "rules": { "type": "array", "items": { "type": "string" }, "description": "The ids of the rules that denied, sorted: what an override names." },
221
+ "holds": { "type": "array", "items": { "$ref": "#/$defs/policyFinding" }, "description": "Rules that hold the plan for a person's approval of its digest." },
222
+ "warnings": { "type": "array", "items": { "type": "string" } },
223
+ "error": { "type": "string" },
224
+ "override": {
225
+ "type": "object",
226
+ "required": ["by", "at", "reason", "digest"],
227
+ "additionalProperties": false,
228
+ "description": "The override on _gates/policy-override.jsonl that lets the denied plan through.",
229
+ "properties": {
230
+ "by": { "type": "string" },
231
+ "at": { "type": "string" },
232
+ "reason": { "type": "string" },
233
+ "digest": { "type": "string", "description": "The override digest: the op, the plan digest and the rules." }
234
+ }
235
+ },
236
+ "overrideCommand": { "type": "string", "description": "While denied: the command that overrides the denial." }
237
+ }
238
+ }
239
+ },
240
+ "$defs": {
241
+ "policyFinding": {
242
+ "type": "object",
243
+ "required": ["rule", "message"],
244
+ "additionalProperties": false,
245
+ "properties": {
246
+ "rule": { "type": "string", "description": "<package>.<rule>, such as main.deny_public_bucket." },
247
+ "message": { "type": "string" }
248
+ }
178
249
  }
179
250
  }
180
251
  }
@@ -109,6 +109,10 @@ export const SECURITY_FEATURES: readonly SecurityFeature[] = [
109
109
  { feature: "Apply jobs require a runner-start statement", byDefault: "off", setting: "`--require-runner-start`", group: "isolated-runners", codes: ["security-runner-start-no-key", "security-runner-start-no-role", "security-trust-unreadable"] },
110
110
  { feature: "Secrets file", byDefault: "off", setting: "`--secrets`", group: null, codes: [] },
111
111
  { feature: "Env allowlist and log redaction", byDefault: "on: `ci run`'s contract, a listed exception", setting: "`--pass-env`", group: null, codes: [] },
112
+ // #4295: the plan job's steps never hold a forge token; the plan-note job posts. Part of the generated pipeline, so nothing turns it on.
113
+ { feature: "Plan steps hold no forge token", byDefault: "on: the plan job's contract, a listed exception", setting: "regenerate the pipeline", group: null, codes: [] },
114
+ // #4296: the plan job trades its OIDC token for the plan identity before the checkout, and the apply jobs take the apply identity.
115
+ { feature: "Separate plan and apply cloud identities", byDefault: "off: no job requests an OIDC token", setting: "`ci.identity.cloud`", group: null, codes: [] },
112
116
  ];
113
117
 
114
118
  /** The reference page's table of {@link SECURITY_FEATURES}, as markdown. */
@@ -24,6 +24,7 @@ import { samePlanDigest } from "../lifecycle/plan-digest";
24
24
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
25
25
  import { isPendingGateExpired, latestPendingGate, latestResolutionForPlan, parseGateLedger } from "../lifecycle/gate-ledger";
26
26
  import { humanQuorumSince, tallyGateApprovals, type GateApprovalRule } from "../op/gate";
27
+ import type { GateQuorumJudge } from "../op/gate-quorum";
27
28
  import type { ReasonCode } from "./reason-codes";
28
29
  import type { ReleaseRevocation } from "../lifecycle/release-revocation";
29
30
  import { CHANGE_SETS_DIR, changeSetFile } from "../lifecycle/change-set-store";
@@ -188,7 +189,7 @@ export function approveCommand(component: string, gate: string, env: string | nu
188
189
  const approvalOf = (r: GateResolutionRecord): StatusGateApproval => ({ principal: r.resolvedBy, channel: r.origin ?? null, relayedBy: r.relayedBy ?? null, at: r.timestamp });
189
190
 
190
191
  /** One gate in one environment, decided against its newest pending fact. */
191
- function decide(component: string, standing: PendingGateRecord, facts: PendingGateRecord[], all: GateResolutionRecord[], now: string, rule: GateApprovalRule | null): StatusGate {
192
+ function decide(component: string, standing: PendingGateRecord, facts: PendingGateRecord[], all: GateResolutionRecord[], now: string, rule: GateApprovalRule | null, judge?: GateQuorumJudge): StatusGate {
192
193
  const gate = standing.gate;
193
194
  // #3163: under identity.gates, an unsigned approval, or one from outside the class, doesn't count, as a run reads it.
194
195
  // #4045: nor does an approval for another release, and an `empty` pass is never an approval.
@@ -208,8 +209,9 @@ function decide(component: string, standing: PendingGateRecord, facts: PendingGa
208
209
  let mismatched: GateResolutionRecord | undefined;
209
210
  if (standing.approval) {
210
211
  // #3206: a human approval from before a fact re-recorded for a mode or context change still counts.
211
- const tally = tallyGateApprovals(own, gate, since, planDigest, standing.approval, humanQuorumSince(facts, standing));
212
- approved = tally.permit !== undefined || tally.counted.length >= tally.need;
212
+ // #4290: approval rules count roles from .chant/trust.json at base, for a proven approver.
213
+ const tally = tallyGateApprovals(own, gate, since, planDigest, standing.approval, humanQuorumSince(facts, standing), judge);
214
+ approved = tally.permit !== undefined || tally.met;
213
215
  approvals = tally.permit && !tally.counted.includes(tally.permit) ? [...tally.counted, tally.permit] : tally.counted;
214
216
  needed = tally.need;
215
217
  mismatched = tally.mismatched;
@@ -249,7 +251,7 @@ function decide(component: string, standing: PendingGateRecord, facts: PendingGa
249
251
  * gate whose other facts have one is left out, as a run leaves it out: such a
250
252
  * line binds no environment, and `chant approve --expire` writes one.
251
253
  */
252
- export function gatesInLedger(component: string, content: string, envs: readonly string[], now: string, rules: GateRuleLookup = () => null): { gates: StatusGate[]; malformed: number } {
254
+ export function gatesInLedger(component: string, content: string, envs: readonly string[], now: string, rules: GateRuleLookup = () => null, judge?: GateQuorumJudge): { gates: StatusGate[]; malformed: number } {
253
255
  const { pending, resolutions, malformed } = parseGateLedger(content);
254
256
  const gates: StatusGate[] = [];
255
257
  for (const gate of [...new Set(pending.map((p) => p.gate))].sort()) {
@@ -264,7 +266,7 @@ export function gatesInLedger(component: string, content: string, envs: readonly
264
266
  }
265
267
  for (const env of [...byEnv.keys()].sort((a, b) => envs.indexOf(a ?? "") - envs.indexOf(b ?? ""))) {
266
268
  const envFacts = byEnv.get(env)!;
267
- gates.push(decide(component, latestPendingGate(envFacts, gate)!, envFacts, resolutions, now, rules(gate)));
269
+ gates.push(decide(component, latestPendingGate(envFacts, gate)!, envFacts, resolutions, now, rules(gate), judge));
268
270
  }
269
271
  }
270
272
  return { gates, malformed };
@@ -281,6 +283,7 @@ export async function readMemberGates(
281
283
  read: GateLedgerReader = defaultGateLedgerReader,
282
284
  rules: GateRuleLookup = () => null,
283
285
  listChangeSets: ChangeSetLister = defaultChangeSetLister,
286
+ judge?: GateQuorumJudge,
284
287
  ): Promise<{ gates: StatusGate[]; ledger: StatusGateLedger }> {
285
288
  const ledger: StatusGateLedger = { layout, path: dir, shared: false, malformed: 0, reason: null };
286
289
  if (commit === null) {
@@ -298,7 +301,7 @@ export async function readMemberGates(
298
301
  }
299
302
  const gates: StatusGate[] = [];
300
303
  for (const component of [...files.keys()].sort()) {
301
- const read = gatesInLedger(component, files.get(component)!, envs, now, rules);
304
+ const read = gatesInLedger(component, files.get(component)!, envs, now, rules, judge);
302
305
  gates.push(...read.gates);
303
306
  ledger.malformed += read.malformed;
304
307
  }
@@ -61,6 +61,7 @@ import { gateAdmissionFrom, releaseRuleFrom } from "./identity";
61
61
  import { checkReleaseSignature, type GateAdmissionRule, type ReleaseSignatureCheck } from "./identity-gate-rule";
62
62
  import { scopeSource } from "./write-scope";
63
63
  import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
64
+ import { trustQuorumJudge } from "../op/gate-quorum";
64
65
  import { STEWARD_REASON_CODES, readMemberStewards, type StatusSteward, type StewardReasonCode } from "./status-stewards";
65
66
  import { gitTop, workingTree } from "./tree";
66
67
  import { handToRootChant } from "./which-chant";
@@ -718,6 +719,8 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
718
719
  const ruleSource = scopeSource(found.dir);
719
720
  const rules = (gate: string) => gateAdmissionFrom(ruleSource, gate);
720
721
  const releaseRule = releaseRuleFrom(ruleSource).rule;
722
+ // #4290: approval rules read roles from the trust policy at base, for a proven approver.
723
+ const judge = trustQuorumJudge({ signers: ruleSource.policy, roles: ruleSource.policy.roles });
721
724
  // #4033 (ws-105): revoked commits, read from local tags once and only when a release is met.
722
725
  // Tags that can't be judged read as none revoked; workspace check reports the declaration.
723
726
  let revokedCommits: Promise<ReadonlyMap<string, ReleaseRevocation>> | undefined;
@@ -730,7 +733,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
730
733
  const environments: StatusEnvironment[] = [];
731
734
  for (const env of envs) environments.push(await readEnvironment(m, env, found.dir, read, revokedOf, query.readOutcomes));
732
735
  const own = await hasMemberLedger(m, found.dir);
733
- const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates, rules);
736
+ const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates, rules, undefined, judge);
734
737
  const stewards = await (query.readStewards ?? readMemberStewards)(resolve(found.dir, m.dir), query.env, now, m.kind, m.box);
735
738
  // The declaration's x- keys go back on the objects that hold them (#3595).
736
739
  const x = (pointer: string) => extensionsAt(declaration, pointer);
@@ -11,7 +11,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { afterAll, describe, expect, test } from "vitest";
14
- import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
14
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "../../lifecycle/gate-seal";
15
15
  import { checkGateSeal } from "./seal";
16
16
  import { emptyPolicy, parseAllowedSigners, type TrustPolicy } from "./policy";
17
17
  import { hasSshKeygen } from "./test-repo";
@@ -30,10 +30,10 @@ import type { SealCode } from "../records";
30
30
  import { type TrustPolicy } from "./policy";
31
31
  import type { Signer } from "./trust-files";
32
32
  import { sshKeygen, verifySshSignature } from "./ssh-commit";
33
- import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./gate-seal";
33
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "../../lifecycle/gate-seal";
34
34
  import { RELEASE_SEAL_NAMESPACE, releaseSealPayload } from "../identity-gate-rule";
35
35
 
36
- export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "./gate-seal";
36
+ export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "../../lifecycle/gate-seal";
37
37
 
38
38
  /** The ssh signature namespace of a verdict seal. */
39
39
  export const REVIEW_SEAL_NAMESPACE = "chant-review";
@@ -1 +0,0 @@
1
- {"version":3,"file":"gate-seal.d.ts","sourceRoot":"","sources":["../../../src/workspace/trust/gate-seal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAE5C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,eAAe,CAAC;AAEhD,kIAAkI;AAClI,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iGAAiG;IACjG,WAAW,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzF,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,kBAAkB,GAAG,kBAAkB,CAWxE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,kBAAkB,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAGtG;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,CAKpE;AA8BD,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtD;AAmCD,iDAAiD;AACjD,MAAM,MAAM,iBAAiB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1F;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,iBAAiB,CA0CtI;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAU9D;AAID;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,6BAA6B;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,uGAAuG;IACvG,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID,kJAAkJ;AAClJ,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAYrF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,EAAE,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,IAAI,CAmB1F"}