@intentius/chant 0.80.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (414) hide show
  1. package/dist/audit/discover.d.ts +8 -16
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-html.d.ts +5 -0
  4. package/dist/audit/report-html.d.ts.map +1 -1
  5. package/dist/audit/report-model.d.ts +13 -0
  6. package/dist/audit/report-model.d.ts.map +1 -1
  7. package/dist/audit/terraform-state.d.ts +4 -5
  8. package/dist/audit/terraform-state.d.ts.map +1 -1
  9. package/dist/cli/commands/audit.d.ts +25 -9
  10. package/dist/cli/commands/audit.d.ts.map +1 -1
  11. package/dist/cli/commands/check-lexicon.d.ts +8 -0
  12. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts.map +1 -1
  14. package/dist/cli/commands/import-agents.d.ts.map +1 -1
  15. package/dist/cli/commands/init.d.ts.map +1 -1
  16. package/dist/cli/commands/lint.d.ts.map +1 -1
  17. package/dist/cli/commands/update.d.ts +6 -0
  18. package/dist/cli/commands/update.d.ts.map +1 -1
  19. package/dist/cli/commands/vendor.d.ts +5 -0
  20. package/dist/cli/commands/vendor.d.ts.map +1 -1
  21. package/dist/cli/handlers/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/components.d.ts.map +1 -1
  23. package/dist/cli/handlers/dev.d.ts.map +1 -1
  24. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  25. package/dist/cli/handlers/init.d.ts.map +1 -1
  26. package/dist/cli/handlers/operator.d.ts +7 -0
  27. package/dist/cli/handlers/operator.d.ts.map +1 -1
  28. package/dist/cli/handlers/promote.d.ts +7 -4
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/run-generate.d.ts.map +1 -1
  31. package/dist/cli/handlers/run.d.ts +4 -1
  32. package/dist/cli/handlers/run.d.ts.map +1 -1
  33. package/dist/cli/handlers/vendor.d.ts +8 -3
  34. package/dist/cli/handlers/vendor.d.ts.map +1 -1
  35. package/dist/cli/main.d.ts +12 -0
  36. package/dist/cli/main.d.ts.map +1 -1
  37. package/dist/cli/plugins.d.ts +12 -7
  38. package/dist/cli/plugins.d.ts.map +1 -1
  39. package/dist/cli/registry.d.ts +32 -0
  40. package/dist/cli/registry.d.ts.map +1 -1
  41. package/dist/codegen/fetch.d.ts +10 -0
  42. package/dist/codegen/fetch.d.ts.map +1 -1
  43. package/dist/components/cli-support.d.ts +3 -1
  44. package/dist/components/cli-support.d.ts.map +1 -1
  45. package/dist/components/discover.d.ts.map +1 -1
  46. package/dist/components/driver.d.ts +16 -2
  47. package/dist/components/driver.d.ts.map +1 -1
  48. package/dist/components/gate-plan.d.ts +40 -0
  49. package/dist/components/gate-plan.d.ts.map +1 -0
  50. package/dist/components/promote.d.ts +60 -7
  51. package/dist/components/promote.d.ts.map +1 -1
  52. package/dist/components/sandbox/driver.d.ts.map +1 -1
  53. package/dist/components/sandbox/run.d.ts.map +1 -1
  54. package/dist/components/verbs/build-archive.d.ts +22 -11
  55. package/dist/components/verbs/build-archive.d.ts.map +1 -1
  56. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  57. package/dist/config-static.d.ts +59 -0
  58. package/dist/config-static.d.ts.map +1 -0
  59. package/dist/config.d.ts +32 -10
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/content-digest.d.ts +17 -0
  62. package/dist/content-digest.d.ts.map +1 -0
  63. package/dist/discovery/files.d.ts +10 -5
  64. package/dist/discovery/files.d.ts.map +1 -1
  65. package/dist/discovery/fold-import.d.ts +20 -4
  66. package/dist/discovery/fold-import.d.ts.map +1 -1
  67. package/dist/discovery/index.d.ts.map +1 -1
  68. package/dist/discovery/sandbox/driver.d.ts +20 -0
  69. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  70. package/dist/discovery/sandbox/fork.d.ts +20 -0
  71. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  72. package/dist/discovery/walk.d.ts +119 -0
  73. package/dist/discovery/walk.d.ts.map +1 -0
  74. package/dist/graph-ir.d.ts +4 -1
  75. package/dist/graph-ir.d.ts.map +1 -1
  76. package/dist/join-key.d.ts +31 -0
  77. package/dist/join-key.d.ts.map +1 -0
  78. package/dist/lexicon-module.d.ts +51 -6
  79. package/dist/lexicon-module.d.ts.map +1 -1
  80. package/dist/lexicon.d.ts +58 -1
  81. package/dist/lexicon.d.ts.map +1 -1
  82. package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
  83. package/dist/lifecycle/digest.d.ts +5 -1
  84. package/dist/lifecycle/digest.d.ts.map +1 -1
  85. package/dist/lifecycle/gate-ledger.d.ts +20 -5
  86. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  87. package/dist/lifecycle/git.d.ts +64 -0
  88. package/dist/lifecycle/git.d.ts.map +1 -1
  89. package/dist/lifecycle/lease.d.ts +8 -1
  90. package/dist/lifecycle/lease.d.ts.map +1 -1
  91. package/dist/lifecycle/legacy-digest.d.ts +41 -8
  92. package/dist/lifecycle/legacy-digest.d.ts.map +1 -1
  93. package/dist/lifecycle/member-ledger.d.ts +67 -0
  94. package/dist/lifecycle/member-ledger.d.ts.map +1 -0
  95. package/dist/lifecycle/plan-digest.d.ts +4 -3
  96. package/dist/lifecycle/plan-digest.d.ts.map +1 -1
  97. package/dist/lifecycle/release-ledger.d.ts +18 -2
  98. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  99. package/dist/lifecycle/status.d.ts +90 -5
  100. package/dist/lifecycle/status.d.ts.map +1 -1
  101. package/dist/op/activities/activity-contracts.d.ts +32 -0
  102. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  103. package/dist/op/activities/index.d.ts +2 -0
  104. package/dist/op/activities/index.d.ts.map +1 -1
  105. package/dist/op/activities/propose-upgrade.d.ts +75 -0
  106. package/dist/op/activities/propose-upgrade.d.ts.map +1 -0
  107. package/dist/op/discover.d.ts.map +1 -1
  108. package/dist/op/gate-name.d.ts +7 -0
  109. package/dist/op/gate-name.d.ts.map +1 -1
  110. package/dist/op/gate-summary.d.ts +16 -0
  111. package/dist/op/gate-summary.d.ts.map +1 -1
  112. package/dist/op/gate.d.ts +16 -2
  113. package/dist/op/gate.d.ts.map +1 -1
  114. package/dist/project-root.d.ts +26 -0
  115. package/dist/project-root.d.ts.map +1 -1
  116. package/dist/workspace/__fixtures__/contract-repo.d.ts +37 -0
  117. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -0
  118. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +11 -0
  119. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -0
  120. package/dist/workspace/checks/generated.d.ts +156 -0
  121. package/dist/workspace/checks/generated.d.ts.map +1 -0
  122. package/dist/workspace/checks/ledgers.d.ts +78 -0
  123. package/dist/workspace/checks/ledgers.d.ts.map +1 -0
  124. package/dist/workspace/checks/links.d.ts +25 -0
  125. package/dist/workspace/checks/links.d.ts.map +1 -0
  126. package/dist/workspace/checks/pipelines.d.ts +76 -0
  127. package/dist/workspace/checks/pipelines.d.ts.map +1 -0
  128. package/dist/workspace/checks.d.ts +161 -0
  129. package/dist/workspace/checks.d.ts.map +1 -0
  130. package/dist/workspace/compose-graph.d.ts +115 -0
  131. package/dist/workspace/compose-graph.d.ts.map +1 -0
  132. package/dist/workspace/compose-reports.d.ts +68 -0
  133. package/dist/workspace/compose-reports.d.ts.map +1 -0
  134. package/dist/workspace/declaration.d.ts +220 -0
  135. package/dist/workspace/declaration.d.ts.map +1 -0
  136. package/dist/workspace/declaration.schema.json +434 -0
  137. package/dist/workspace/generated-files.d.ts +65 -0
  138. package/dist/workspace/generated-files.d.ts.map +1 -0
  139. package/dist/workspace/glob.d.ts +13 -0
  140. package/dist/workspace/glob.d.ts.map +1 -0
  141. package/dist/workspace/graph-cli.d.ts +75 -0
  142. package/dist/workspace/graph-cli.d.ts.map +1 -0
  143. package/dist/workspace/init.d.ts +96 -0
  144. package/dist/workspace/init.d.ts.map +1 -0
  145. package/dist/workspace/jsonc.d.ts +38 -0
  146. package/dist/workspace/jsonc.d.ts.map +1 -0
  147. package/dist/workspace/kinds.d.ts +192 -0
  148. package/dist/workspace/kinds.d.ts.map +1 -0
  149. package/dist/workspace/lineage-check.d.ts +84 -0
  150. package/dist/workspace/lineage-check.d.ts.map +1 -0
  151. package/dist/workspace/lineage-cli.d.ts +31 -0
  152. package/dist/workspace/lineage-cli.d.ts.map +1 -0
  153. package/dist/workspace/lineage-init.d.ts +101 -0
  154. package/dist/workspace/lineage-init.d.ts.map +1 -0
  155. package/dist/workspace/lineage-lock.d.ts +258 -0
  156. package/dist/workspace/lineage-lock.d.ts.map +1 -0
  157. package/dist/workspace/lineage-merge.d.ts +14 -0
  158. package/dist/workspace/lineage-merge.d.ts.map +1 -0
  159. package/dist/workspace/lineage-migrations.d.ts +178 -0
  160. package/dist/workspace/lineage-migrations.d.ts.map +1 -0
  161. package/dist/workspace/lineage-update.d.ts +59 -0
  162. package/dist/workspace/lineage-update.d.ts.map +1 -0
  163. package/dist/workspace/lineage-upgrade-cli.d.ts +40 -0
  164. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -0
  165. package/dist/workspace/lineage-upgrade.d.ts +146 -0
  166. package/dist/workspace/lineage-upgrade.d.ts.map +1 -0
  167. package/dist/workspace/lineage-vendor.d.ts +63 -0
  168. package/dist/workspace/lineage-vendor.d.ts.map +1 -0
  169. package/dist/workspace/lineage-version.d.ts +32 -0
  170. package/dist/workspace/lineage-version.d.ts.map +1 -0
  171. package/dist/workspace/links.d.ts +150 -0
  172. package/dist/workspace/links.d.ts.map +1 -0
  173. package/dist/workspace/ls.d.ts +99 -0
  174. package/dist/workspace/ls.d.ts.map +1 -0
  175. package/dist/workspace/member-commands.d.ts +139 -0
  176. package/dist/workspace/member-commands.d.ts.map +1 -0
  177. package/dist/workspace/member-handles.d.ts +54 -0
  178. package/dist/workspace/member-handles.d.ts.map +1 -0
  179. package/dist/workspace/member-pipeline.d.ts +143 -0
  180. package/dist/workspace/member-pipeline.d.ts.map +1 -0
  181. package/dist/workspace/member-run.d.ts +67 -0
  182. package/dist/workspace/member-run.d.ts.map +1 -0
  183. package/dist/workspace/reason-codes.d.ts +65 -0
  184. package/dist/workspace/reason-codes.d.ts.map +1 -0
  185. package/dist/workspace/records-cli.d.ts +28 -4
  186. package/dist/workspace/records-cli.d.ts.map +1 -1
  187. package/dist/workspace/records.d.ts.map +1 -1
  188. package/dist/workspace/root-refusal.d.ts +53 -0
  189. package/dist/workspace/root-refusal.d.ts.map +1 -0
  190. package/dist/workspace/status.d.ts +150 -0
  191. package/dist/workspace/status.d.ts.map +1 -0
  192. package/dist/workspace/template-manifest.d.ts +90 -0
  193. package/dist/workspace/template-manifest.d.ts.map +1 -0
  194. package/dist/workspace/tree.d.ts +36 -0
  195. package/dist/workspace/tree.d.ts.map +1 -0
  196. package/dist/workspace/trust/attestor.d.ts +67 -0
  197. package/dist/workspace/trust/attestor.d.ts.map +1 -0
  198. package/dist/workspace/trust/policy.d.ts +111 -0
  199. package/dist/workspace/trust/policy.d.ts.map +1 -0
  200. package/dist/workspace/trust/provenance.d.ts +67 -0
  201. package/dist/workspace/trust/provenance.d.ts.map +1 -0
  202. package/dist/workspace/trust/ssh-commit.d.ts +43 -0
  203. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -0
  204. package/dist/workspace/trust/test-repo.d.ts +42 -0
  205. package/dist/workspace/trust/test-repo.d.ts.map +1 -0
  206. package/dist/workspace/trust/verify-cli.d.ts +9 -0
  207. package/dist/workspace/trust/verify-cli.d.ts.map +1 -0
  208. package/dist/workspace/trust/verify.d.ts +66 -0
  209. package/dist/workspace/trust/verify.d.ts.map +1 -0
  210. package/dist/workspace/which-chant.d.ts +58 -0
  211. package/dist/workspace/which-chant.d.ts.map +1 -0
  212. package/dist/workspace/workspace-kinds.schema.json +133 -0
  213. package/package.json +2 -1
  214. package/src/audit/discover.ts +66 -78
  215. package/src/audit/report-html.ts +3 -1
  216. package/src/audit/report-model.ts +14 -1
  217. package/src/audit/terraform-state.test.ts +4 -9
  218. package/src/audit/terraform-state.ts +4 -5
  219. package/src/cli/commands/audit-truncation.test.ts +167 -0
  220. package/src/cli/commands/audit.ts +89 -60
  221. package/src/cli/commands/check-lexicon.ts +24 -0
  222. package/src/cli/commands/doctor.test.ts +30 -0
  223. package/src/cli/commands/doctor.ts +18 -0
  224. package/src/cli/commands/import-agents.ts +6 -2
  225. package/src/cli/commands/init.ts +22 -3
  226. package/src/cli/commands/lint.test.ts +17 -23
  227. package/src/cli/commands/lint.ts +24 -84
  228. package/src/cli/commands/update.ts +13 -4
  229. package/src/cli/commands/vendor.ts +11 -6
  230. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +38 -0
  231. package/src/cli/handlers/build-member.test.ts +127 -0
  232. package/src/cli/handlers/build.ts +35 -1
  233. package/src/cli/handlers/components.test.ts +127 -0
  234. package/src/cli/handlers/components.ts +70 -8
  235. package/src/cli/handlers/dev.ts +5 -3
  236. package/src/cli/handlers/fan-out.ts +9 -2
  237. package/src/cli/handlers/init.ts +54 -1
  238. package/src/cli/handlers/operator.test.ts +68 -0
  239. package/src/cli/handlers/operator.ts +37 -9
  240. package/src/cli/handlers/promote.test.ts +118 -2
  241. package/src/cli/handlers/promote.ts +82 -16
  242. package/src/cli/handlers/run-generate-member.test.ts +83 -0
  243. package/src/cli/handlers/run-generate.test.ts +6 -8
  244. package/src/cli/handlers/run-generate.ts +36 -1
  245. package/src/cli/handlers/run.test.ts +47 -0
  246. package/src/cli/handlers/run.ts +28 -5
  247. package/src/cli/handlers/vendor.ts +147 -43
  248. package/src/cli/main.test.ts +105 -1
  249. package/src/cli/main.ts +230 -17
  250. package/src/cli/plugins.ts +17 -17
  251. package/src/cli/registry.ts +32 -0
  252. package/src/cli/static-config-read.test.ts +208 -0
  253. package/src/codegen/fetch.test.ts +63 -1
  254. package/src/codegen/fetch.ts +30 -1
  255. package/src/components/cli-support.ts +6 -1
  256. package/src/components/discover.test.ts +27 -57
  257. package/src/components/discover.ts +13 -68
  258. package/src/components/driver.test.ts +78 -1
  259. package/src/components/driver.ts +43 -7
  260. package/src/components/gate-plan.ts +53 -0
  261. package/src/components/promote.test.ts +276 -2
  262. package/src/components/promote.ts +119 -15
  263. package/src/components/sandbox/driver.ts +8 -8
  264. package/src/components/sandbox/run.ts +4 -1
  265. package/src/components/verbs/build-archive.ts +27 -21
  266. package/src/components/verbs/run-agent.ts +3 -5
  267. package/src/config-static.test.ts +136 -0
  268. package/src/config-static.ts +232 -0
  269. package/src/config.test.ts +2 -2
  270. package/src/config.ts +48 -15
  271. package/src/content-digest.test.ts +120 -0
  272. package/src/content-digest.ts +21 -0
  273. package/src/discovery/files.test.ts +10 -10
  274. package/src/discovery/files.ts +25 -81
  275. package/src/discovery/fold-import.ts +49 -5
  276. package/src/discovery/index.ts +1 -0
  277. package/src/discovery/sandbox/driver.ts +73 -35
  278. package/src/discovery/sandbox/fork-diagnostic.test.ts +136 -13
  279. package/src/discovery/sandbox/fork.ts +83 -43
  280. package/src/discovery/sandbox/path-lexicon-multifile.test.ts +285 -0
  281. package/src/discovery/walk.test.ts +263 -0
  282. package/src/discovery/walk.ts +334 -0
  283. package/src/effect-receipt.ts +2 -2
  284. package/src/graph-ir.ts +4 -1
  285. package/src/join-key.test.ts +25 -0
  286. package/src/join-key.ts +39 -0
  287. package/src/lexicon-module.ts +108 -10
  288. package/src/lexicon.ts +79 -1
  289. package/src/lifecycle/build-ledger-store.ts +11 -10
  290. package/src/lifecycle/digest.ts +9 -5
  291. package/src/lifecycle/gate-ledger.test.ts +20 -0
  292. package/src/lifecycle/gate-ledger.ts +28 -6
  293. package/src/lifecycle/git.test.ts +13 -1
  294. package/src/lifecycle/git.ts +221 -176
  295. package/src/lifecycle/lease.ts +25 -10
  296. package/src/lifecycle/legacy-digest.test.ts +115 -23
  297. package/src/lifecycle/legacy-digest.ts +60 -19
  298. package/src/lifecycle/member-ledger.test.ts +319 -0
  299. package/src/lifecycle/member-ledger.ts +145 -0
  300. package/src/lifecycle/plan-digest.ts +7 -7
  301. package/src/lifecycle/release-ledger.test.ts +20 -0
  302. package/src/lifecycle/release-ledger.ts +23 -7
  303. package/src/lifecycle/status.test.ts +151 -1
  304. package/src/lifecycle/status.ts +228 -7
  305. package/src/op/activities/activity-contracts.ts +37 -0
  306. package/src/op/activities/index.ts +10 -0
  307. package/src/op/activities/propose-upgrade.ts +206 -0
  308. package/src/op/discover.test.ts +14 -4
  309. package/src/op/discover.ts +19 -27
  310. package/src/op/gate-name.ts +8 -0
  311. package/src/op/gate-summary.test.ts +14 -0
  312. package/src/op/gate-summary.ts +25 -2
  313. package/src/op/gate.ts +36 -7
  314. package/src/op/generate-pipeline.test.ts +14 -5
  315. package/src/project-root.test.ts +66 -1
  316. package/src/project-root.ts +48 -0
  317. package/src/workspace/__fixtures__/contract-repo.ts +100 -0
  318. package/src/workspace/__fixtures__/two-member-workspace.ts +35 -0
  319. package/src/workspace/behold-kinds.test.ts +162 -0
  320. package/src/workspace/check-contract.test.ts +127 -0
  321. package/src/workspace/check.schema.json +347 -0
  322. package/src/workspace/checks/generated.test.ts +287 -0
  323. package/src/workspace/checks/generated.ts +475 -0
  324. package/src/workspace/checks/ledgers.test.ts +139 -0
  325. package/src/workspace/checks/ledgers.ts +246 -0
  326. package/src/workspace/checks/links.ts +183 -0
  327. package/src/workspace/checks/pipelines.test.ts +133 -0
  328. package/src/workspace/checks/pipelines.ts +225 -0
  329. package/src/workspace/checks-repo.test.ts +71 -0
  330. package/src/workspace/checks.test.ts +450 -0
  331. package/src/workspace/checks.ts +461 -0
  332. package/src/workspace/compose-graph.test.ts +146 -0
  333. package/src/workspace/compose-graph.ts +228 -0
  334. package/src/workspace/compose-reports.test.ts +109 -0
  335. package/src/workspace/compose-reports.ts +186 -0
  336. package/src/workspace/declaration.schema.json +434 -0
  337. package/src/workspace/declaration.test.ts +347 -0
  338. package/src/workspace/declaration.ts +623 -0
  339. package/src/workspace/generated-files.test.ts +114 -0
  340. package/src/workspace/generated-files.ts +94 -0
  341. package/src/workspace/glob.ts +49 -0
  342. package/src/workspace/graph-cli.ts +220 -0
  343. package/src/workspace/graph-contract.test.ts +204 -0
  344. package/src/workspace/graph.schema.json +662 -0
  345. package/src/workspace/init.test.ts +223 -0
  346. package/src/workspace/init.ts +585 -0
  347. package/src/workspace/jsonc.test.ts +65 -0
  348. package/src/workspace/jsonc.ts +231 -0
  349. package/src/workspace/kinds.test.ts +288 -0
  350. package/src/workspace/kinds.ts +506 -0
  351. package/src/workspace/lineage-check.ts +264 -0
  352. package/src/workspace/lineage-cli.ts +114 -0
  353. package/src/workspace/lineage-init.test.ts +231 -0
  354. package/src/workspace/lineage-init.ts +365 -0
  355. package/src/workspace/lineage-lock.test.ts +224 -0
  356. package/src/workspace/lineage-lock.ts +357 -0
  357. package/src/workspace/lineage-merge.ts +44 -0
  358. package/src/workspace/lineage-migrations.test.ts +178 -0
  359. package/src/workspace/lineage-migrations.ts +343 -0
  360. package/src/workspace/lineage-update.ts +160 -0
  361. package/src/workspace/lineage-upgrade-cli.ts +151 -0
  362. package/src/workspace/lineage-upgrade.test.ts +474 -0
  363. package/src/workspace/lineage-upgrade.ts +624 -0
  364. package/src/workspace/lineage-vendor.test.ts +126 -0
  365. package/src/workspace/lineage-vendor.ts +215 -0
  366. package/src/workspace/lineage-version.ts +127 -0
  367. package/src/workspace/links.test.ts +344 -0
  368. package/src/workspace/links.ts +331 -0
  369. package/src/workspace/ls-contract.test.ts +200 -0
  370. package/src/workspace/ls.schema.json +190 -0
  371. package/src/workspace/ls.ts +254 -0
  372. package/src/workspace/member-commands.e2e.test.ts +244 -0
  373. package/src/workspace/member-commands.test.ts +217 -0
  374. package/src/workspace/member-commands.ts +540 -0
  375. package/src/workspace/member-handles.test.ts +76 -0
  376. package/src/workspace/member-handles.ts +228 -0
  377. package/src/workspace/member-pipeline.test.ts +135 -0
  378. package/src/workspace/member-pipeline.ts +317 -0
  379. package/src/workspace/member-run.ts +146 -0
  380. package/src/workspace/read-contract.test.ts +129 -0
  381. package/src/workspace/reason-codes.test.ts +113 -0
  382. package/src/workspace/reason-codes.ts +81 -0
  383. package/src/workspace/records-cli.ts +86 -14
  384. package/src/workspace/records-contract.test.ts +5 -0
  385. package/src/workspace/records.schema.json +32 -2
  386. package/src/workspace/records.ts +3 -2
  387. package/src/workspace/root-refusal.ts +97 -0
  388. package/src/workspace/status-contract.test.ts +308 -0
  389. package/src/workspace/status.schema.json +210 -0
  390. package/src/workspace/status.ts +412 -0
  391. package/src/workspace/template-manifest.test.ts +79 -0
  392. package/src/workspace/template-manifest.ts +221 -0
  393. package/src/workspace/tree.ts +132 -0
  394. package/src/workspace/trust/attestor.ts +93 -0
  395. package/src/workspace/trust/policy.ts +306 -0
  396. package/src/workspace/trust/provenance.ts +181 -0
  397. package/src/workspace/trust/ssh-commit.ts +138 -0
  398. package/src/workspace/trust/test-repo.ts +138 -0
  399. package/src/workspace/trust/trust.test.ts +397 -0
  400. package/src/workspace/trust/verify-cli.ts +56 -0
  401. package/src/workspace/trust/verify.ts +194 -0
  402. package/src/workspace/which-chant.test.ts +117 -0
  403. package/src/workspace/which-chant.ts +151 -0
  404. package/src/workspace/workspace-kinds.schema.json +133 -0
  405. package/dist/components/unbound-gate-approval.d.ts +0 -24
  406. package/dist/components/unbound-gate-approval.d.ts.map +0 -1
  407. package/dist/discovery/convergence.d.ts +0 -97
  408. package/dist/discovery/convergence.d.ts.map +0 -1
  409. package/src/cli/commands/audit-walk-warnings.test.ts +0 -156
  410. package/src/components/unbound-gate-approval.test.ts +0 -82
  411. package/src/components/unbound-gate-approval.ts +0 -51
  412. package/src/discovery/convergence.test.ts +0 -231
  413. package/src/discovery/convergence.ts +0 -399
  414. package/src/op/discover-convergence.test.ts +0 -70
@@ -13,7 +13,8 @@
13
13
  import { describe, expect, it } from "vitest";
14
14
  import { CapabilityRegistry, type DeployContext } from "./capability";
15
15
  import { stubCapability } from "./verbs/stub";
16
- import { memoryGateLedgerPort } from "../op/gate";
16
+ import { describeGateMismatch, memoryGateLedgerPort } from "../op/gate";
17
+ import { componentPlanDigest } from "./gate-plan";
17
18
  import type { PendingGateInput } from "../lifecycle/gate-ledger";
18
19
  import {
19
20
  DependencyCycleError,
@@ -311,14 +312,17 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
311
312
 
312
313
  it("a resolution newer than the pending fact passes the gate and carries the approver", async () => {
313
314
  const { registry, calls } = registryWithCalls();
315
+ const planDigest = componentPlanDigest({ environment: "dev", component: gatedComponent() });
314
316
  const port = memoryGateLedgerPort({
315
317
  pending: [{
316
318
  version: 1, kind: "pending", op: "neo4j-cluster", gate: "approve-node-1",
317
319
  timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2026-09-07T10:00:00.000Z",
320
+ planDigest, environment: "dev",
318
321
  }],
319
322
  resolutions: [{
320
323
  version: 1, op: "neo4j-cluster", gate: "approve-node-1",
321
324
  resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z",
325
+ planDigest, environment: "dev",
322
326
  }],
323
327
  });
324
328
  const result = await runComponentDeploy(
@@ -332,6 +336,79 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
332
336
  .toEqual({ gate: "approve-node-1", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z" });
333
337
  });
334
338
 
339
+ // #2574: a component gate approval is bound to the environment and the plan.
340
+ describe("approvals bound to environment and plan (#2574)", () => {
341
+ const run = (port: ReturnType<typeof memoryGateLedgerPort>, env: string, component = gatedComponent()) =>
342
+ runComponentDeploy(component, { env, component: "neo4j-cluster" }, registryWithCalls().registry, {}, undefined, { port, now: NOW });
343
+
344
+ it("the pending fact records the environment and the plan", async () => {
345
+ const port = memoryGateLedgerPort();
346
+ const result = await run(port, "staging");
347
+ expect(result.gate).toMatchObject({
348
+ environment: "staging",
349
+ planDigest: componentPlanDigest({ environment: "staging", component: gatedComponent() }),
350
+ });
351
+ });
352
+
353
+ it("an approval given for another environment does not pass the gate", async () => {
354
+ const port = memoryGateLedgerPort();
355
+ const staging = await run(port, "staging");
356
+ const approval = {
357
+ version: 1 as const, op: "neo4j-cluster", gate: "approve-node-1", resolvedBy: "alex",
358
+ timestamp: "2026-09-05T12:30:00.000Z",
359
+ planDigest: staging.gate!.planDigest!, environment: "staging",
360
+ };
361
+ const withApproval = memoryGateLedgerPort({ pending: [staging.gate!], resolutions: [approval] });
362
+
363
+ expect((await run(withApproval, "staging")).status).toBe("ok");
364
+ const prod = await run(withApproval, "prod");
365
+ expect(prod.status).toBe("gated");
366
+ expect(prod.gate).toMatchObject({ environment: "prod" });
367
+ // The staging approval is not this run's business, so it is not reported as a mismatch either.
368
+ expect(prod.gateMismatch).toBeUndefined();
369
+ });
370
+
371
+ it("an approval given for another plan in the same environment is refused and named", async () => {
372
+ const port = memoryGateLedgerPort();
373
+ const before = await run(port, "prod");
374
+ const approval = {
375
+ version: 1 as const, op: "neo4j-cluster", gate: "approve-node-1", resolvedBy: "alex",
376
+ timestamp: "2026-09-05T12:30:00.000Z",
377
+ planDigest: before.gate!.planDigest!, environment: "prod",
378
+ };
379
+ const changed = gatedComponent();
380
+ changed.deploy[1]!.steps.push({ kind: "cfn-deploy", stack: "extra" });
381
+ const result = await run(memoryGateLedgerPort({ pending: [before.gate!], resolutions: [approval] }), "prod", changed);
382
+
383
+ expect(result.status).toBe("gated");
384
+ expect(result.gateMismatch).toMatchObject({
385
+ approved: before.gate!.planDigest, planned: result.gate!.planDigest, environment: "prod", resolvedBy: "alex",
386
+ });
387
+ expect(result.gate!.planDigest).not.toBe(before.gate!.planDigest);
388
+ });
389
+
390
+ it("an approval recorded before the binding is refused, with the command that replaces it", async () => {
391
+ const legacyPending = {
392
+ version: 1 as const, kind: "pending" as const, op: "neo4j-cluster", gate: "approve-node-1",
393
+ timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2026-09-07T10:00:00.000Z",
394
+ };
395
+ const legacy = {
396
+ version: 1 as const, op: "neo4j-cluster", gate: "approve-node-1",
397
+ resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z",
398
+ };
399
+ const port = memoryGateLedgerPort({ pending: [legacyPending], resolutions: [legacy] });
400
+ const result = await run(port, "prod");
401
+
402
+ expect(result.status).toBe("gated");
403
+ expect(port.appended).toHaveLength(1);
404
+ expect(result.gateMismatch).toMatchObject({ resolvedBy: "alex", environment: "prod" });
405
+ expect(result.gateMismatch!.approved).toBeUndefined();
406
+ const message = describeGateMismatch("neo4j-cluster", "approve-node-1", result.gateMismatch!);
407
+ expect(message).toContain("predates environment- and plan-bound component gates (#2574)");
408
+ expect(message).toContain("chant approve neo4j-cluster approve-node-1 --env prod");
409
+ });
410
+ });
411
+
335
412
  it("the neo4j pilot's gated Node 1 phase ends the run pending approval", async () => {
336
413
  const registry = new CapabilityRegistry();
337
414
  for (const kind of ["cfn-deploy", "code-deploy", "wait-cluster-healthy"]) {
@@ -47,9 +47,9 @@
47
47
  */
48
48
 
49
49
  import { topoSort } from "../codegen/topo-sort";
50
- import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
50
+ import { evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "../op/gate";
51
51
  import { gateName } from "../op/gate-name";
52
- import { warnOnUnboundComponentApproval } from "./unbound-gate-approval";
52
+ import { componentPlanDigest } from "./gate-plan";
53
53
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
54
54
  import type { CapabilityRegistry, DeployContext } from "./capability";
55
55
  import type { RunProgressEvent, RunProgressStatus } from "./run-progress";
@@ -168,6 +168,8 @@ export interface DriverComponentResult {
168
168
  gatePushed?: boolean;
169
169
  /** Set when `gatePushed` is false: why, in one line. */
170
170
  gatePushWarning?: string;
171
+ /** Present when `status === "gated"` because an approval stands for this gate, but for another plan or none (#2574). */
172
+ gateMismatch?: GateDigestMismatch;
171
173
  }
172
174
 
173
175
  export interface DriverRunResult {
@@ -189,6 +191,8 @@ export interface DriverRunResult {
189
191
  gatePushed?: boolean;
190
192
  /** Set when `gatePushed` is false: why, in one line. */
191
193
  gatePushWarning?: string;
194
+ /** Present when `status === "gated"` because an approval stands for another plan or none (#2574). */
195
+ gateMismatch?: GateDigestMismatch;
192
196
  /**
193
197
  * The accumulated cross-component/cross-stack outputs after the run — each
194
198
  * component's `publish` output and, for an applied stack, its `cfn-deploy`
@@ -371,6 +375,8 @@ class GateStop extends Error {
371
375
  /** Whether this run's own append reached the remote — see {@link DriverComponentResult.gatePushed} (#2310). */
372
376
  public readonly pushed?: boolean,
373
377
  public readonly pushWarning?: string,
378
+ /** Set when an approval stands for this gate but not for this plan (#2574). */
379
+ public readonly mismatch?: GateDigestMismatch,
374
380
  ) {
375
381
  super(`gate "${pending.gate}" is pending approval`);
376
382
  this.name = "GateStop";
@@ -386,6 +392,14 @@ function progressStatus(status: DriverComponentResult["status"]): RunProgressSta
386
392
  export interface GateContext {
387
393
  port: GateLedgerPort;
388
394
  now?: string;
395
+ /**
396
+ * The recorded digest each component is deployed at, for a promote or a
397
+ * rollback (#2574). It goes into the plan digest a gate approval is bound
398
+ * to, keyed by component name.
399
+ */
400
+ releases?: ReadonlyMap<string, string>;
401
+ /** This component's plan digest. Set by {@link runComponentDeploy}, never by a caller. */
402
+ planDigest?: string;
389
403
  }
390
404
 
391
405
  /** Run a single capability step. Never throws for a capability-run failure; returns a fail record instead. */
@@ -468,6 +482,9 @@ async function runPhase(
468
482
  const check = await evaluateGate(gates.port, {
469
483
  op: ctx.component,
470
484
  gate: gateName(gate),
485
+ // #2574: an approval counts only for this environment and this plan.
486
+ environment: ctx.env,
487
+ ...(gates.planDigest !== undefined ? { planDigest: gates.planDigest } : {}),
471
488
  ...(gate.description ? { description: gate.description } : {}),
472
489
  ...(gate.timeout ? { timeout: gate.timeout } : {}),
473
490
  ...(gates.now ? { now: gates.now } : {}),
@@ -478,9 +495,8 @@ async function runPhase(
478
495
  for (const skipped of phaseDef.steps.filter((s): s is DriverStep | DriverPhase => !isGateStep(s))) {
479
496
  gateRecords.push(skippedRecord(skipped));
480
497
  }
481
- throw new GateStop(gateRecords, [], check.pending, check.pushed, check.pushWarning);
498
+ throw new GateStop(gateRecords, [], check.pending, check.pushed, check.pushWarning, check.mismatch);
482
499
  }
483
- warnOnUnboundComponentApproval(ctx.component, gateName(gate), ctx.env, check.resolution);
484
500
  gateRecords.push({
485
501
  ...base,
486
502
  status: "ok" as const,
@@ -507,6 +523,7 @@ async function runPhase(
507
523
  pending?: PendingGateRecord;
508
524
  pushed?: boolean;
509
525
  pushWarning?: string;
526
+ mismatch?: GateDigestMismatch;
510
527
  }> => {
511
528
  if (isPhaseStep(entry)) {
512
529
  try {
@@ -524,6 +541,7 @@ async function runPhase(
524
541
  pending: err.pending,
525
542
  pushed: err.pushed,
526
543
  pushWarning: err.pushWarning,
544
+ mismatch: err.mismatch,
527
545
  };
528
546
  }
529
547
  throw err;
@@ -572,7 +590,7 @@ async function runPhase(
572
590
  const executed = results.flatMap((r) => r.executed);
573
591
  const withPending = results.find((r) => r.pending);
574
592
  if (withPending?.pending) {
575
- throw new GateStop(records, executed, withPending.pending, withPending.pushed, withPending.pushWarning);
593
+ throw new GateStop(records, executed, withPending.pending, withPending.pushed, withPending.pushWarning, withPending.mismatch);
576
594
  }
577
595
  if (results.some((r) => r.failed)) throw new StepFailure(records, executed);
578
596
  return { records, executed };
@@ -586,7 +604,7 @@ async function runPhase(
586
604
  executed.push(...result.executed);
587
605
  if (result.pending) {
588
606
  for (const skipped of entries.slice(i + 1)) records.push(skippedRecord(skipped));
589
- throw new GateStop(records, executed, result.pending, result.pushed, result.pushWarning);
607
+ throw new GateStop(records, executed, result.pending, result.pushed, result.pushWarning, result.mismatch);
590
608
  }
591
609
  if (result.failed) {
592
610
  for (const skipped of entries.slice(i + 1)) records.push(skippedRecord(skipped));
@@ -695,9 +713,22 @@ export async function runComponentDeploy(
695
713
  registry: CapabilityRegistry,
696
714
  componentOutputs: Record<string, Record<string, unknown>>,
697
715
  onProgress?: (event: RunProgressEvent) => void,
698
- gates: GateContext = { port: gitGateLedgerPort() },
716
+ gateContext: GateContext = { port: gitGateLedgerPort() },
699
717
  ): Promise<DriverComponentResult> {
700
718
  const phaseOutputs: Record<string, Record<string, unknown>> = {};
719
+ // #2574: every gate this component reaches binds its approval to this
720
+ // environment and this composition, so an approval for staging or for last
721
+ // week's deploy cannot pass it.
722
+ const release = gateContext.releases?.get(component.name);
723
+ const gates: GateContext = {
724
+ ...gateContext,
725
+ planDigest: componentPlanDigest({
726
+ environment: ctx.env,
727
+ component,
728
+ ...(ctx.vars ? { vars: ctx.vars } : {}),
729
+ ...(release !== undefined ? { release } : {}),
730
+ }),
731
+ };
701
732
  const records: DriverStepRecord[] = [];
702
733
  const allExecuted: ExecutedStep[] = [];
703
734
 
@@ -722,6 +753,7 @@ export async function runComponentDeploy(
722
753
  gate: err.pending,
723
754
  ...(err.pushed !== undefined ? { gatePushed: err.pushed } : {}),
724
755
  ...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
756
+ ...(err.mismatch ? { gateMismatch: err.mismatch } : {}),
725
757
  };
726
758
  }
727
759
 
@@ -875,6 +907,8 @@ export interface InterpretRunOptions {
875
907
  gates?: GateLedgerPort;
876
908
  /** ISO-8601 "now" for gate decisions, so a test is deterministic. */
877
909
  now?: string;
910
+ /** The recorded digest a promote or rollback deploys, per component (#2574). Part of the plan a gate approval is bound to. */
911
+ releases?: ReadonlyMap<string, string>;
878
912
  }
879
913
 
880
914
  /**
@@ -908,6 +942,7 @@ export async function runInterpretDriver(
908
942
  const gates: GateContext = {
909
943
  port: options.gates ?? gitGateLedgerPort(),
910
944
  ...(options.now ? { now: options.now } : {}),
945
+ ...(options.releases ? { releases: options.releases } : {}),
911
946
  };
912
947
 
913
948
  onProgress?.({ type: "run-start", waves });
@@ -959,6 +994,7 @@ export async function runInterpretDriver(
959
994
  gate: gated.gate,
960
995
  ...(gated.gatePushed !== undefined ? { gatePushed: gated.gatePushed } : {}),
961
996
  ...(gated.gatePushWarning ? { gatePushWarning: gated.gatePushWarning } : {}),
997
+ ...(gated.gateMismatch ? { gateMismatch: gated.gateMismatch } : {}),
962
998
  }
963
999
  : {}),
964
1000
  };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The plan a component gate approval is bound to (#2574).
3
+ *
4
+ * An Op gate binds the digest of the change set its Plan phase produced
5
+ * (#2300). A component has no Plan phase: what it will do is its composition,
6
+ * run against one environment. So the digest is taken over that, with the
7
+ * same `computePlanDigest` Op gates use.
8
+ *
9
+ * ## What it covers
10
+ *
11
+ * - The environment. An approval for staging never answers prod.
12
+ * - The composition as this run executes it: `deploy` and `rollback` phases
13
+ * with every step's authored input. A promote and a rollback run a
14
+ * transformed composition (`./promote.ts`), and a rollback's pins the
15
+ * recorded digest into it, so each differs from a plain deploy's.
16
+ * - The release being deployed, when the caller names one. A promote checks
17
+ * its publish output against a recorded digest that is not in the
18
+ * composition, so it passes that digest here.
19
+ * - `vars`, the environment config the caller resolved ahead of the run.
20
+ *
21
+ * ## What it leaves out
22
+ *
23
+ * Anything decided during the run: outputs of earlier phases (a fresh build's
24
+ * digest differs on every build that is not reproducible, and binding it
25
+ * would make an approval unusable by the run that consumes it) and outputs of
26
+ * upstream components. The run id and the time are left out for the reasons
27
+ * `../lifecycle/plan-digest.ts` gives.
28
+ */
29
+
30
+ import { computePlanDigest } from "../lifecycle/plan-digest";
31
+ import type { DriverComponent } from "./driver";
32
+
33
+ /** What a component gate's plan digest is taken over. */
34
+ export interface ComponentGatePlan {
35
+ environment: string;
36
+ component: DriverComponent;
37
+ vars?: Record<string, unknown>;
38
+ /** The recorded digest a promote or rollback deploys, when the caller has one. */
39
+ release?: string;
40
+ }
41
+
42
+ /** The plan digest a component gate records and approves. */
43
+ export function componentPlanDigest(plan: ComponentGatePlan): string {
44
+ const { component } = plan;
45
+ return computePlanDigest("component-deploy", {
46
+ environment: plan.environment,
47
+ component: component.name,
48
+ deploy: component.deploy,
49
+ rollback: component.rollback ?? null,
50
+ vars: plan.vars ?? null,
51
+ release: plan.release ?? null,
52
+ });
53
+ }
@@ -4,20 +4,25 @@
4
4
 
5
5
  import { describe, test, expect } from "vitest";
6
6
  import { CapabilityRegistry, type DeployContext } from "./capability";
7
- import { memoryGateLedgerPort, type GateLedgerPort } from "../op/gate";
7
+ import { describeGateMismatch, memoryGateLedgerPort, type GateLedgerPort } from "../op/gate";
8
+ import { componentPlanDigest } from "./gate-plan";
8
9
  import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
9
10
  import type { DriverComponent } from "./driver";
10
11
  import type { ReleaseRecord } from "../lifecycle/release-ledger";
11
12
  import {
12
13
  planPromotion,
13
14
  planRollback,
15
+ planRedeploy,
14
16
  rollbackRecord,
17
+ redeployRecord,
15
18
  selectRelease,
16
19
  withoutBuildSteps,
17
20
  runPromotion,
18
21
  promotionRecord,
19
22
  gateApprover,
20
23
  promoteArchivePaths,
24
+ hasPublishStep,
25
+ parseDigestPins,
21
26
  type PromotionPlan,
22
27
  } from "./promote";
23
28
 
@@ -102,6 +107,72 @@ describe("planning", () => {
102
107
  });
103
108
  });
104
109
 
110
+ describe("pinning each component to a digest (#2602)", () => {
111
+ // api's pinned release is older than its latest one: another pipeline
112
+ // recorded sha256:newer while this one ran.
113
+ const records = [
114
+ rec("api", "staging", "sha256:built", "2026-01-02T00:00:00Z"),
115
+ rec("api", "staging", "sha256:newer", "2026-01-03T00:00:00Z"),
116
+ rec("web", "staging", "sha256:web", "2026-01-02T00:00:00Z"),
117
+ rec("infra", "staging", "sha256:infra", "2026-01-02T00:00:00Z"),
118
+ ];
119
+ const declared = ["api", "web", "infra"];
120
+
121
+ test("promotes exactly the pinned components at the pinned digests, not the latest", () => {
122
+ const plan = planPromotion({
123
+ from: "staging", to: "prod", sourceRecords: records, declared,
124
+ pins: { web: "sha256:web", api: "sha256:built" },
125
+ });
126
+ expect("items" in plan).toBe(true);
127
+ const { items, notPromoted } = plan as PromotionPlan;
128
+ expect(items.map((i) => [i.component, i.digest, i.source.timestamp])).toEqual([
129
+ ["api", "sha256:built", "2026-01-02T00:00:00Z"],
130
+ ["web", "sha256:web", "2026-01-02T00:00:00Z"],
131
+ ]);
132
+ expect(notPromoted).toEqual([]);
133
+ });
134
+
135
+ test("a pinned digest the source never recorded fails", () => {
136
+ const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared, pins: { api: "sha256:nope" } });
137
+ expect("error" in plan && plan.error).toMatch(/sha256:nope is not recorded for "api" in "staging"/);
138
+ });
139
+
140
+ test("a pinned component this checkout does not declare fails", () => {
141
+ const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared, pins: { gone: "sha256:x" } });
142
+ expect("error" in plan && plan.error).toMatch(/"gone" is not declared/);
143
+ });
144
+
145
+ test("pins and --component do not mix", () => {
146
+ const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared, component: "api", pins: { api: "sha256:built" } });
147
+ expect("error" in plan && plan.error).toMatch(/does not take --component/);
148
+ });
149
+
150
+ test("parseDigestPins reads <component>=<digest> values", () => {
151
+ expect(parseDigestPins(["api=sha256:a", "web=sha256:w"])).toEqual({ pins: { api: "sha256:a", web: "sha256:w" } });
152
+ expect(parseDigestPins(["api=sha256:a", "api=sha256:a"])).toEqual({ pins: { api: "sha256:a" } });
153
+ });
154
+
155
+ test("parseDigestPins refuses an empty digest, a bare digest, and two digests for one component", () => {
156
+ const err = (values: string[]) => {
157
+ const parsed = parseDigestPins(values);
158
+ return "error" in parsed ? parsed.error : undefined;
159
+ };
160
+ expect(err(["api="])).toMatch(/names no digest; the run that deployed "api" recorded no release/);
161
+ expect(err(["sha256:a"])).toMatch(/needs --component/);
162
+ expect(err(["=sha256:a"])).toMatch(/names no component/);
163
+ expect(err(["api=sha256:a", "api=sha256:b"])).toMatch(/pins "api" twice/);
164
+ });
165
+
166
+ test("hasPublishStep finds a publish step, nested phases included", () => {
167
+ expect(hasPublishStep(service("api"))).toBe(true);
168
+ expect(hasPublishStep({ name: "infra", deploy: [{ phase: "Apply", steps: [{ kind: "apply" }] }] })).toBe(false);
169
+ expect(hasPublishStep({
170
+ name: "nested",
171
+ deploy: [{ phase: "Outer", steps: [{ phase: "Inner", steps: [{ kind: "publish-artifact" }] }] }],
172
+ })).toBe(true);
173
+ });
174
+ });
175
+
105
176
  describe("taking the build out of a composition", () => {
106
177
  test("build steps go, publish and apply stay", () => {
107
178
  const prepared = withoutBuildSteps(service("api"));
@@ -216,10 +287,24 @@ describe("running a promotion", () => {
216
287
  expect(first.gate?.gate).toBe("prod-release");
217
288
  expect(ran).not.toContain("prod:api:apply");
218
289
 
219
- resolutions.push({ version: 1, kind: "resolution", op: "api", gate: "prod-release", resolvedBy: "alice", timestamp: "2026-01-03T00:01:00Z" });
290
+ // `chant approve` copies the pending fact's environment and plan (#2574).
291
+ const bound = { planDigest: first.gate!.planDigest!, environment: first.gate!.environment! };
292
+ expect(bound.environment).toBe("prod");
293
+ resolutions.push({ version: 1, kind: "resolution", op: "api", gate: "prod-release", resolvedBy: "alice", timestamp: "2026-01-03T00:01:00Z", ...bound });
220
294
  const second = await runPromotion({ ...opts, now: "2026-01-03T00:02:00Z" });
221
295
  expect(second.status).toBe("ok");
222
296
  expect(gateApprover(second.results[0])).toBe("alice");
297
+
298
+ // The same approval does not pass a promote of another release (#2574).
299
+ const other = await runPromotion({
300
+ ...opts,
301
+ plan: planFor("api", "sha256:other"),
302
+ registry: fakeRegistry({ api: "sha256:other" }).registry,
303
+ now: "2026-01-03T00:03:00Z",
304
+ });
305
+ expect(other.status).toBe("gated");
306
+ expect(other.gateMismatch).toMatchObject({ approved: bound.planDigest, resolvedBy: "alice", environment: "prod" });
307
+ expect(other.gate?.planDigest).not.toBe(bound.planDigest);
223
308
  });
224
309
 
225
310
  test("dependencies outside the promotion do not block it", async () => {
@@ -300,6 +385,195 @@ describe("choosing the release a rollback restores", () => {
300
385
  });
301
386
  });
302
387
 
388
+ describe("choosing the release a redeploy puts back (#2604)", () => {
389
+ const history = [
390
+ rec("api", "prod", "sha256:a", "2026-01-01T00:00:00Z"),
391
+ rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2"),
392
+ ];
393
+ const plan = (input: Partial<Parameters<typeof planRedeploy>[0]>) =>
394
+ planRedeploy({ env: "prod", records: history, declared: ["api"], component: "api", ...input });
395
+
396
+ test("the release the ledger records as current", () => {
397
+ const p = plan({});
398
+ if ("error" in p) throw new Error(p.error);
399
+ expect(p.from).toBe("prod");
400
+ expect(p.to).toBe("prod");
401
+ expect(p.items).toEqual([{ component: "api", digest: "sha256:b", source: history[1] }]);
402
+ });
403
+
404
+ test("a --digest naming the current release is accepted", () => {
405
+ const p = plan({ digest: "sha256:b" });
406
+ expect("items" in p && p.items[0].digest).toBe("sha256:b");
407
+ });
408
+
409
+ test("a --digest naming any other release is refused and points at rollback", () => {
410
+ const p = plan({ digest: "sha256:a" });
411
+ expect("error" in p && p.error).toMatch(/sha256:a is not the current release of "api" in "prod" \(that is sha256:b\)/);
412
+ expect("error" in p && p.error).toMatch(/chant components rollback prod --component api --digest sha256:a/);
413
+ });
414
+
415
+ test("a component with no release in the environment is refused", () => {
416
+ const p = plan({ records: [] });
417
+ expect("error" in p && p.error).toMatch(/no release of "api" is recorded in "prod"/);
418
+ });
419
+
420
+ test("a component this checkout does not declare is refused", () => {
421
+ const p = plan({ declared: ["web"] });
422
+ expect("error" in p && p.error).toMatch(/not declared in this checkout/);
423
+ });
424
+
425
+ test("the record names the release it put back", () => {
426
+ const p = plan({});
427
+ if ("error" in p) throw new Error(p.error);
428
+ const record = redeployRecord(p.items[0], "prod", { runId: "run-5", actor: "bob", timestamp: "2026-01-05T00:00:00Z" });
429
+ expect(record).toMatchObject({
430
+ env: "prod",
431
+ digest: "sha256:b",
432
+ redeploys: { env: "prod", runId: "run-2", timestamp: "2026-01-02T00:00:00Z" },
433
+ });
434
+ expect(record).not.toHaveProperty("restores");
435
+ expect(record).not.toHaveProperty("promotedFrom");
436
+ });
437
+
438
+ test("a refusal from the pinned composition names the redeploy", () => {
439
+ const c = service("api");
440
+ c.deploy[2].steps[0] = { kind: "apply", image: "@Publish.uri" };
441
+ const p = withoutBuildSteps(c, { pinDigest: "sha256:b", verb: "a redeploy" });
442
+ expect("error" in p && p.error).toMatch(/but a redeploy knows only the recorded digest/);
443
+ });
444
+ });
445
+
446
+ describe("a redeploy's gate approval is bound like a rollback's (#2574, #2604)", () => {
447
+ /** A gated service, pinned the way `chant components redeploy` pins it. */
448
+ const gatedService = (): DriverComponent => {
449
+ const c = service("api");
450
+ c.deploy[2].steps.unshift({ kind: "gate", gate: "prod-release" });
451
+ return c;
452
+ };
453
+ const pinned = (digest: string): DriverComponent => {
454
+ const p = withoutBuildSteps(gatedService(), { pinDigest: digest, verb: "a redeploy" });
455
+ if ("error" in p) throw new Error(p.error);
456
+ return p.component;
457
+ };
458
+ const redeployOf = (records: ReleaseRecord[]): PromotionPlan => {
459
+ const p = planRedeploy({ env: "prod", records, declared: ["api"], component: "api" });
460
+ if ("error" in p) throw new Error(p.error);
461
+ return p;
462
+ };
463
+ /** A ledger port whose resolutions the test writes, as `chant approve` would. */
464
+ const ledger = () => {
465
+ const resolutions: GateResolutionRecord[] = [];
466
+ const memory = memoryGateLedgerPort();
467
+ const gates: GateLedgerPort = {
468
+ async read(op) {
469
+ const read = await memory.read(op);
470
+ return { ...read, resolutions: [...resolutions] };
471
+ },
472
+ appendPending: (input) => memory.appendPending(input),
473
+ };
474
+ return { resolutions, gates };
475
+ };
476
+ const approval = (bound: Partial<GateResolutionRecord>, timestamp: string): GateResolutionRecord => ({
477
+ version: 1,
478
+ kind: "resolution",
479
+ op: "api",
480
+ gate: "prod-release",
481
+ resolvedBy: "alice",
482
+ timestamp,
483
+ ...bound,
484
+ });
485
+
486
+ test("the pending fact records prod and the redeploy's plan, and an approval of it lets the same redeploy through", async () => {
487
+ const { resolutions, gates } = ledger();
488
+ const { registry, ran } = fakeRegistry({ api: "sha256:unused" });
489
+ const plan = redeployOf([rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2")]);
490
+ const opts = { plan, components: [pinned("sha256:b")], registry, gates };
491
+
492
+ const first = await runPromotion({ ...opts, now: "2026-01-05T00:00:00Z" });
493
+ expect(first.status).toBe("gated");
494
+ expect(first.gate?.environment).toBe("prod");
495
+ expect(first.gate?.planDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
496
+ expect(ran).toEqual([]);
497
+
498
+ resolutions.push(approval({ planDigest: first.gate!.planDigest!, environment: "prod" }, "2026-01-05T00:01:00Z"));
499
+ const second = await runPromotion({ ...opts, now: "2026-01-05T00:02:00Z" });
500
+ expect(second.status).toBe("ok");
501
+ expect(gateApprover(second.results[0])).toBe("alice");
502
+ expect(ran).toEqual(["prod:api:apply"]);
503
+ });
504
+
505
+ test("an approval for another environment, for a plain deploy, or from before #2574 does not pass it", async () => {
506
+ const { resolutions, gates } = ledger();
507
+ const { registry, ran } = fakeRegistry({ api: "sha256:unused" });
508
+ const plan = redeployOf([rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2")]);
509
+ const opts = { plan, components: [pinned("sha256:b")], registry, gates };
510
+ const first = await runPromotion({ ...opts, now: "2026-01-05T00:00:00Z" });
511
+ const planned = first.gate!.planDigest!;
512
+
513
+ // The same plan approved for staging is not read at all in prod.
514
+ resolutions.push(approval({ planDigest: planned, environment: "staging" }, "2026-01-05T00:01:00Z"));
515
+ const staging = await runPromotion({ ...opts, now: "2026-01-05T00:02:00Z" });
516
+ expect(staging.status).toBe("gated");
517
+ expect(staging.gateMismatch).toBeUndefined();
518
+
519
+ // An approval of a plain deploy to prod is for another plan.
520
+ const deployPlan = componentPlanDigest({ environment: "prod", component: gatedService() });
521
+ resolutions.push(approval({ planDigest: deployPlan, environment: "prod" }, "2026-01-05T00:03:00Z"));
522
+ const plain = await runPromotion({ ...opts, now: "2026-01-05T00:04:00Z" });
523
+ expect(plain.status).toBe("gated");
524
+ expect(plain.gateMismatch).toMatchObject({ approved: deployPlan, planned, environment: "prod" });
525
+
526
+ // One recorded before #2574 names neither, and the refusal says how to approve again.
527
+ resolutions.push(approval({}, "2026-01-05T00:05:00Z"));
528
+ const old = await runPromotion({ ...opts, now: "2026-01-05T00:06:00Z" });
529
+ expect(old.status).toBe("gated");
530
+ expect(old.gateMismatch).toMatchObject({ planned, environment: "prod" });
531
+ expect(old.gateMismatch?.approved).toBeUndefined();
532
+ expect(describeGateMismatch("api", "prod-release", old.gateMismatch!)).toMatch(
533
+ /predates environment- and plan-bound component gates \(#2574\).*chant approve api prod-release --env prod$/,
534
+ );
535
+ expect(ran).toEqual([]);
536
+ });
537
+
538
+ test("an approval of one redeploy does not pass a redeploy of a release recorded since", async () => {
539
+ const { resolutions, gates } = ledger();
540
+ const { registry } = fakeRegistry({ api: "sha256:unused" });
541
+ const b = rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2");
542
+ const first = await runPromotion({ plan: redeployOf([b]), components: [pinned("sha256:b")], registry, gates, now: "2026-01-05T00:00:00Z" });
543
+ const approved = first.gate!.planDigest!;
544
+ resolutions.push(approval({ planDigest: approved, environment: "prod" }, "2026-01-05T00:01:00Z"));
545
+
546
+ const c = rec("api", "prod", "sha256:c", "2026-01-06T00:00:00Z", "run-3");
547
+ const next = await runPromotion({ plan: redeployOf([b, c]), components: [pinned("sha256:c")], registry, gates, now: "2026-01-06T00:01:00Z" });
548
+ expect(next.status).toBe("gated");
549
+ expect(next.gateMismatch).toMatchObject({ approved, resolvedBy: "alice", environment: "prod" });
550
+ expect(next.gate?.planDigest).not.toBe(approved);
551
+ });
552
+
553
+ test("a current release recorded with a legacy digest (#2514) binds the approval to that digest as recorded", async () => {
554
+ const legacy = `sha256:${"0a1b2c3d".repeat(8)}`;
555
+ const { resolutions, gates } = ledger();
556
+ const { registry, ran } = fakeRegistry({ api: "sha256:unused" });
557
+ const opts = {
558
+ plan: redeployOf([rec("api", "prod", legacy, "2026-01-02T00:00:00Z", "run-2")]),
559
+ components: [pinned(legacy)],
560
+ registry,
561
+ gates,
562
+ };
563
+ const first = await runPromotion({ ...opts, now: "2026-01-05T00:00:00Z" });
564
+ expect(first.status).toBe("gated");
565
+ const planned = first.gate!.planDigest!;
566
+ expect(planned).toBe(componentPlanDigest({ environment: "prod", component: pinned(legacy), release: legacy }));
567
+ expect(planned).not.toBe(legacy);
568
+
569
+ resolutions.push(approval({ planDigest: planned, environment: "prod" }, "2026-01-05T00:01:00Z"));
570
+ const second = await runPromotion({ ...opts, now: "2026-01-05T00:02:00Z" });
571
+ expect(second.status).toBe("ok");
572
+ expect(second.results[0].records.find((r) => r.kind === "apply")?.output).toEqual({ applied: legacy });
573
+ expect(ran).toEqual(["prod:api:apply"]);
574
+ });
575
+ });
576
+
303
577
  describe("pinning the publish step for a rollback", () => {
304
578
  test("the publish step is replaced by the recorded digest", () => {
305
579
  const p = withoutBuildSteps(service("api"), { pinDigest: "sha256:b" });