@intentius/chant 0.46.0 → 0.50.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 (377) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +30 -3
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +9 -2
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts +3 -3
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/cli/commands/audit.d.ts +7 -0
  20. package/dist/cli/commands/audit.d.ts.map +1 -1
  21. package/dist/cli/commands/build.d.ts +23 -0
  22. package/dist/cli/commands/build.d.ts.map +1 -1
  23. package/dist/cli/commands/lint.d.ts.map +1 -1
  24. package/dist/cli/handlers/build.d.ts.map +1 -1
  25. package/dist/cli/handlers/components.d.ts +31 -0
  26. package/dist/cli/handlers/components.d.ts.map +1 -1
  27. package/dist/cli/handlers/lifecycle.d.ts +12 -1
  28. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/scenario.d.ts +39 -0
  32. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  35. package/dist/cli/mcp/server.d.ts +35 -2
  36. package/dist/cli/mcp/server.d.ts.map +1 -1
  37. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  38. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  39. package/dist/cli/mcp/types.d.ts +29 -1
  40. package/dist/cli/mcp/types.d.ts.map +1 -1
  41. package/dist/cli/plugins.d.ts +1 -1
  42. package/dist/cli/plugins.d.ts.map +1 -1
  43. package/dist/cli/registry.d.ts +14 -2
  44. package/dist/cli/registry.d.ts.map +1 -1
  45. package/dist/cli/reporters/stylish.d.ts +15 -1
  46. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  47. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  48. package/dist/components/auto-release.d.ts +4 -0
  49. package/dist/components/auto-release.d.ts.map +1 -1
  50. package/dist/components/capability.d.ts +17 -2
  51. package/dist/components/capability.d.ts.map +1 -1
  52. package/dist/components/cli-support.d.ts +7 -0
  53. package/dist/components/cli-support.d.ts.map +1 -1
  54. package/dist/components/component.d.ts +15 -0
  55. package/dist/components/component.d.ts.map +1 -1
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/starter-plugin.d.ts +2 -0
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  60. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  61. package/dist/components/verbs/index.d.ts +13 -0
  62. package/dist/components/verbs/index.d.ts.map +1 -1
  63. package/dist/components/verbs/r2-sync.d.ts +76 -0
  64. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  65. package/dist/components/verbs/run-agent.d.ts +499 -0
  66. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  67. package/dist/components/verbs/sign.d.ts +30 -0
  68. package/dist/components/verbs/sign.d.ts.map +1 -1
  69. package/dist/components/verbs/wrangler.d.ts +108 -0
  70. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  71. package/dist/composite.d.ts +6 -1
  72. package/dist/composite.d.ts.map +1 -1
  73. package/dist/config.d.ts +26 -0
  74. package/dist/config.d.ts.map +1 -1
  75. package/dist/deep-observation.d.ts +14 -0
  76. package/dist/deep-observation.d.ts.map +1 -1
  77. package/dist/discovery/collect.d.ts.map +1 -1
  78. package/dist/discovery/fold-import.d.ts +15 -1
  79. package/dist/discovery/fold-import.d.ts.map +1 -1
  80. package/dist/discovery/fold-rank.d.ts +66 -0
  81. package/dist/discovery/fold-rank.d.ts.map +1 -0
  82. package/dist/discovery/index.d.ts +15 -0
  83. package/dist/discovery/index.d.ts.map +1 -1
  84. package/dist/discovery/param-deps.d.ts +17 -0
  85. package/dist/discovery/param-deps.d.ts.map +1 -0
  86. package/dist/effect-receipt.d.ts +177 -0
  87. package/dist/effect-receipt.d.ts.map +1 -0
  88. package/dist/fold/fold.d.ts +55 -2
  89. package/dist/fold/fold.d.ts.map +1 -1
  90. package/dist/fold/subset.d.ts +20 -0
  91. package/dist/fold/subset.d.ts.map +1 -1
  92. package/dist/index.d.ts +4 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/lexicon-schema.d.ts +2 -0
  95. package/dist/lexicon-schema.d.ts.map +1 -1
  96. package/dist/lexicon.d.ts +137 -3
  97. package/dist/lexicon.d.ts.map +1 -1
  98. package/dist/lifecycle/change-set.d.ts +33 -5
  99. package/dist/lifecycle/change-set.d.ts.map +1 -1
  100. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  101. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  102. package/dist/lifecycle/deep-diff.d.ts +18 -0
  103. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  104. package/dist/lifecycle/deep-observe.d.ts +9 -1
  105. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  106. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  107. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  108. package/dist/lifecycle/git.d.ts +145 -21
  109. package/dist/lifecycle/git.d.ts.map +1 -1
  110. package/dist/lifecycle/index.d.ts +6 -0
  111. package/dist/lifecycle/index.d.ts.map +1 -1
  112. package/dist/lifecycle/lease.d.ts +113 -0
  113. package/dist/lifecycle/lease.d.ts.map +1 -0
  114. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  115. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  116. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  117. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  118. package/dist/lifecycle/release-ledger.d.ts +20 -0
  119. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  120. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  121. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  122. package/dist/lifecycle/scenario.d.ts +163 -0
  123. package/dist/lifecycle/scenario.d.ts.map +1 -0
  124. package/dist/lifecycle/symptoms.d.ts +63 -0
  125. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  126. package/dist/lifecycle/teardown.d.ts +6 -4
  127. package/dist/lifecycle/teardown.d.ts.map +1 -1
  128. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  129. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  130. package/dist/lint/knowledge-checks.d.ts +48 -0
  131. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  132. package/dist/lint/output-checks.d.ts +5 -0
  133. package/dist/lint/output-checks.d.ts.map +1 -0
  134. package/dist/lint/output-docs.d.ts +94 -0
  135. package/dist/lint/output-docs.d.ts.map +1 -0
  136. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  137. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  138. package/dist/lint/post-synth.d.ts +41 -0
  139. package/dist/lint/post-synth.d.ts.map +1 -1
  140. package/dist/lint/receipt-checks.d.ts +9 -0
  141. package/dist/lint/receipt-checks.d.ts.map +1 -0
  142. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  143. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  144. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  145. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  146. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  147. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  148. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  149. package/dist/lint/rules/index.d.ts +3 -1
  150. package/dist/lint/rules/index.d.ts.map +1 -1
  151. package/dist/lsp/lexicon-providers.d.ts +7 -0
  152. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  153. package/dist/okf-read.d.ts +78 -0
  154. package/dist/okf-read.d.ts.map +1 -0
  155. package/dist/op/activity-contract.d.ts +139 -0
  156. package/dist/op/activity-contract.d.ts.map +1 -0
  157. package/dist/op/builders.d.ts +140 -3
  158. package/dist/op/builders.d.ts.map +1 -1
  159. package/dist/op/converge-rule.d.ts +161 -0
  160. package/dist/op/converge-rule.d.ts.map +1 -0
  161. package/dist/op/generate-pipeline.d.ts +39 -0
  162. package/dist/op/generate-pipeline.d.ts.map +1 -0
  163. package/dist/op/index.d.ts +18 -2
  164. package/dist/op/index.d.ts.map +1 -1
  165. package/dist/op/local-executor.d.ts +2 -1
  166. package/dist/op/local-executor.d.ts.map +1 -1
  167. package/dist/op/op-verb-class.d.ts +42 -0
  168. package/dist/op/op-verb-class.d.ts.map +1 -0
  169. package/dist/op/operator.d.ts +128 -0
  170. package/dist/op/operator.d.ts.map +1 -0
  171. package/dist/op/receipt-store.d.ts +138 -0
  172. package/dist/op/receipt-store.d.ts.map +1 -0
  173. package/dist/op/step-output-ref.d.ts +187 -0
  174. package/dist/op/step-output-ref.d.ts.map +1 -0
  175. package/dist/op/types.d.ts +49 -2
  176. package/dist/op/types.d.ts.map +1 -1
  177. package/dist/provenance.d.ts +73 -3
  178. package/dist/provenance.d.ts.map +1 -1
  179. package/dist/runtime-adapter.d.ts +7 -1
  180. package/dist/runtime-adapter.d.ts.map +1 -1
  181. package/dist/secret-materialization.d.ts +138 -0
  182. package/dist/secret-materialization.d.ts.map +1 -0
  183. package/dist/secret-provenance.d.ts +218 -0
  184. package/dist/secret-provenance.d.ts.map +1 -0
  185. package/dist/serializer.d.ts +29 -0
  186. package/dist/serializer.d.ts.map +1 -1
  187. package/dist/toml.d.ts +40 -5
  188. package/dist/toml.d.ts.map +1 -1
  189. package/dist/yaml.d.ts.map +1 -1
  190. package/package.json +4 -1
  191. package/src/audit/catalog.test.ts +1 -1
  192. package/src/audit/catalog.ts +75 -3
  193. package/src/audit/core.test.ts +57 -0
  194. package/src/audit/core.ts +0 -0
  195. package/src/audit/detect-bundle.test.ts +1 -1
  196. package/src/audit/discover.test.ts +24 -0
  197. package/src/audit/discover.ts +40 -4
  198. package/src/audit/fetch.test.ts +216 -3
  199. package/src/audit/fetch.ts +270 -59
  200. package/src/audit/report-html.ts +5 -2
  201. package/src/audit/report-model.ts +9 -0
  202. package/src/audit/report.test.ts +22 -0
  203. package/src/audit/report.ts +3 -2
  204. package/src/audit/rules-doc.ts +13 -1
  205. package/src/audit/secrets.test.ts +303 -0
  206. package/src/audit/secrets.ts +406 -0
  207. package/src/audit/wrangler.test.ts +230 -0
  208. package/src/audit/wrangler.ts +290 -0
  209. package/src/build.test.ts +41 -0
  210. package/src/build.ts +39 -6
  211. package/src/cli/command-group.ts +1 -1
  212. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  213. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  214. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  215. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  216. package/src/cli/commands/audit.test.ts +268 -1
  217. package/src/cli/commands/audit.ts +87 -18
  218. package/src/cli/commands/build.test.ts +245 -0
  219. package/src/cli/commands/build.ts +210 -21
  220. package/src/cli/commands/lint.ts +15 -3
  221. package/src/cli/handlers/build.ts +2 -0
  222. package/src/cli/handlers/components.test.ts +199 -1
  223. package/src/cli/handlers/components.ts +160 -3
  224. package/src/cli/handlers/explain.test.ts +70 -1
  225. package/src/cli/handlers/graph.test.ts +20 -0
  226. package/src/cli/handlers/graph.ts +12 -3
  227. package/src/cli/handlers/lifecycle.test.ts +115 -1
  228. package/src/cli/handlers/lifecycle.ts +96 -15
  229. package/src/cli/handlers/operator.test.ts +255 -0
  230. package/src/cli/handlers/operator.ts +240 -0
  231. package/src/cli/handlers/scenario.test.ts +456 -0
  232. package/src/cli/handlers/scenario.ts +330 -0
  233. package/src/cli/main.test.ts +23 -0
  234. package/src/cli/main.ts +72 -1
  235. package/src/cli/mcp/resource-handlers.ts +38 -1
  236. package/src/cli/mcp/server.test.ts +323 -3
  237. package/src/cli/mcp/server.ts +84 -7
  238. package/src/cli/mcp/tools/explain.ts +51 -2
  239. package/src/cli/mcp/types.ts +27 -1
  240. package/src/cli/plugins.ts +4 -2
  241. package/src/cli/registry.ts +14 -2
  242. package/src/cli/reporters/stylish.test.ts +154 -0
  243. package/src/cli/reporters/stylish.ts +154 -33
  244. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  245. package/src/codegen/docs-rule-scanning.ts +25 -2
  246. package/src/components/README.md +7 -0
  247. package/src/components/auto-release.ts +6 -0
  248. package/src/components/capability.ts +17 -2
  249. package/src/components/cli-support.test.ts +17 -0
  250. package/src/components/cli-support.ts +13 -1
  251. package/src/components/component-schema.test.ts +32 -0
  252. package/src/components/component.schema.json +6 -0
  253. package/src/components/component.test.ts +21 -0
  254. package/src/components/component.ts +15 -0
  255. package/src/components/driver.ts +12 -4
  256. package/src/components/registry.test.ts +7 -2
  257. package/src/components/starter-plugin.ts +17 -0
  258. package/src/components/verbs/ensure-secret.test.ts +130 -0
  259. package/src/components/verbs/ensure-secret.ts +79 -0
  260. package/src/components/verbs/index.ts +13 -0
  261. package/src/components/verbs/r2-sync.test.ts +107 -0
  262. package/src/components/verbs/r2-sync.ts +124 -0
  263. package/src/components/verbs/run-agent.test.ts +683 -0
  264. package/src/components/verbs/run-agent.ts +786 -0
  265. package/src/components/verbs/sign.test.ts +19 -0
  266. package/src/components/verbs/sign.ts +34 -2
  267. package/src/components/verbs/wrangler.test.ts +170 -0
  268. package/src/components/verbs/wrangler.ts +241 -0
  269. package/src/composite.ts +31 -2
  270. package/src/config.test.ts +15 -0
  271. package/src/config.ts +30 -0
  272. package/src/deep-observation.test.ts +19 -0
  273. package/src/deep-observation.ts +17 -0
  274. package/src/discovery/collect.ts +11 -2
  275. package/src/discovery/fold-import.test.ts +54 -0
  276. package/src/discovery/fold-import.ts +178 -38
  277. package/src/discovery/fold-rank.test.ts +197 -0
  278. package/src/discovery/fold-rank.ts +346 -0
  279. package/src/discovery/index.ts +16 -1
  280. package/src/discovery/param-deps.test.ts +118 -0
  281. package/src/discovery/param-deps.ts +170 -0
  282. package/src/effect-receipt-exclusion.test.ts +190 -0
  283. package/src/effect-receipt.test.ts +419 -0
  284. package/src/effect-receipt.ts +412 -0
  285. package/src/fold/fold.test.ts +6 -2
  286. package/src/fold/fold.ts +184 -3
  287. package/src/fold/subset.test.ts +95 -6
  288. package/src/fold/subset.ts +66 -2
  289. package/src/index.ts +4 -0
  290. package/src/lexicon-schema.ts +3 -0
  291. package/src/lexicon.ts +151 -5
  292. package/src/lifecycle/change-set.ts +46 -7
  293. package/src/lifecycle/converge-ledger.test.ts +199 -0
  294. package/src/lifecycle/converge-ledger.ts +179 -0
  295. package/src/lifecycle/deep-diff.test.ts +79 -1
  296. package/src/lifecycle/deep-diff.ts +23 -0
  297. package/src/lifecycle/deep-observe.ts +13 -2
  298. package/src/lifecycle/gate-ledger.test.ts +103 -0
  299. package/src/lifecycle/gate-ledger.ts +140 -0
  300. package/src/lifecycle/git.test.ts +430 -0
  301. package/src/lifecycle/git.ts +446 -84
  302. package/src/lifecycle/index.ts +6 -0
  303. package/src/lifecycle/lease.test.ts +343 -0
  304. package/src/lifecycle/lease.ts +270 -0
  305. package/src/lifecycle/observation-baseline.test.ts +46 -0
  306. package/src/lifecycle/observation-baseline.ts +33 -1
  307. package/src/lifecycle/receipt-plan.test.ts +250 -0
  308. package/src/lifecycle/receipt-plan.ts +249 -0
  309. package/src/lifecycle/release-ledger.ts +20 -0
  310. package/src/lifecycle/scenario-eval.test.ts +199 -0
  311. package/src/lifecycle/scenario-eval.ts +158 -0
  312. package/src/lifecycle/scenario.test.ts +195 -0
  313. package/src/lifecycle/scenario.ts +321 -0
  314. package/src/lifecycle/symptoms.test.ts +116 -0
  315. package/src/lifecycle/symptoms.ts +126 -0
  316. package/src/lifecycle/teardown.test.ts +31 -0
  317. package/src/lifecycle/teardown.ts +6 -4
  318. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  319. package/src/lifecycle/unobserved-gate.ts +102 -0
  320. package/src/lint/knowledge-checks.test.ts +80 -0
  321. package/src/lint/knowledge-checks.ts +74 -0
  322. package/src/lint/output-checks.test.ts +85 -0
  323. package/src/lint/output-checks.ts +99 -0
  324. package/src/lint/output-docs.test.ts +220 -0
  325. package/src/lint/output-docs.ts +204 -0
  326. package/src/lint/pipeline-change-gate.test.ts +144 -0
  327. package/src/lint/pipeline-change-gate.ts +153 -0
  328. package/src/lint/post-synth.test.ts +97 -0
  329. package/src/lint/post-synth.ts +60 -0
  330. package/src/lint/receipt-checks.test.ts +101 -0
  331. package/src/lint/receipt-checks.ts +93 -0
  332. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  333. package/src/lint/rules/comp/comp.test.ts +49 -1
  334. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  335. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  336. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  337. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  338. package/src/lint/rules/evl001-non-literal-expression.test.ts +35 -3
  339. package/src/lint/rules/evl001-non-literal-expression.ts +7 -0
  340. package/src/lint/rules/index.ts +7 -1
  341. package/src/lsp/lexicon-providers.test.ts +44 -0
  342. package/src/lsp/lexicon-providers.ts +11 -1
  343. package/src/okf-read.test.ts +149 -0
  344. package/src/okf-read.ts +197 -0
  345. package/src/op/activity-contract.test.ts +180 -0
  346. package/src/op/activity-contract.ts +278 -0
  347. package/src/op/builders-exports.test.ts +17 -1
  348. package/src/op/builders.ts +198 -6
  349. package/src/op/converge-rule.test.ts +179 -0
  350. package/src/op/converge-rule.ts +311 -0
  351. package/src/op/effect-step.test.ts +311 -0
  352. package/src/op/generate-pipeline.test.ts +53 -0
  353. package/src/op/generate-pipeline.ts +99 -0
  354. package/src/op/index.ts +40 -3
  355. package/src/op/local-executor.test.ts +92 -0
  356. package/src/op/local-executor.ts +212 -29
  357. package/src/op/op-verb-class.test.ts +126 -0
  358. package/src/op/op-verb-class.ts +115 -0
  359. package/src/op/op.test.ts +25 -2
  360. package/src/op/operator.test.ts +346 -0
  361. package/src/op/operator.ts +213 -0
  362. package/src/op/receipt-store.ts +211 -0
  363. package/src/op/step-output-ref.test.ts +334 -0
  364. package/src/op/step-output-ref.ts +453 -0
  365. package/src/op/types.ts +51 -2
  366. package/src/provenance.test.ts +151 -4
  367. package/src/provenance.ts +118 -4
  368. package/src/runtime-adapter.ts +31 -10
  369. package/src/secret-materialization.test.ts +199 -0
  370. package/src/secret-materialization.ts +235 -0
  371. package/src/secret-provenance.test.ts +388 -0
  372. package/src/secret-provenance.ts +475 -0
  373. package/src/serializer.ts +30 -0
  374. package/src/toml.test.ts +157 -384
  375. package/src/toml.ts +371 -5
  376. package/src/yaml.test.ts +88 -0
  377. package/src/yaml.ts +76 -6
@@ -821,6 +821,120 @@ describe("runLifecyclePlan", () => {
821
821
  expect(byName["sg-0abc123"].name).toBe("sg-0abc123");
822
822
  });
823
823
 
824
+ // #1832 — effect receipts are declared, diffed, and observed like any
825
+ // resource, but observe-only to the generic apply path: the plan compares
826
+ // live value to resolved expectation and proposes the fire, never a create.
827
+ describe("effect receipts (#1832)", () => {
828
+ const receiptEntity = (lexicon: string) => ({
829
+ lexicon,
830
+ entityType: lexicon === "chant" ? "Chant::EffectReceipt" : `${lexicon}::Receipt`,
831
+ name: "dbMigrated",
832
+ effect: "db-migrate",
833
+ flavor: "existence" as const,
834
+ inputs: {},
835
+ [Symbol.for("chant.declarable")]: true,
836
+ [Symbol.for("chant.effect-receipt")]: true,
837
+ });
838
+
839
+ const buildWithReceipt = (lexicon: string): BuildResult => {
840
+ const base = makeBuildResult({ aws: ["bucket"] });
841
+ base.entities.set("dbMigrated", receiptEntity(lexicon) as unknown as Parameters<typeof base.entities.set>[1]);
842
+ if (lexicon !== "aws") (base.manifest.lexicons as string[]).push(lexicon);
843
+ return base;
844
+ };
845
+
846
+ test("an absent receipt is an effect row, never a create", async () => {
847
+ buildMock.mockResolvedValue(buildWithReceipt("aws"));
848
+ const plugins: LexiconPlugin[] = [
849
+ createMockPlugin({
850
+ name: "aws",
851
+ emulator: awsEmulatorStub,
852
+ describeResources: async () => ({
853
+ observation: "v1" as const,
854
+ resources: { bucket: meta() },
855
+ // dbMigrated neither returned nor unobserved: confirmed absent.
856
+ }),
857
+ }),
858
+ ];
859
+ const exit = await runLifecyclePlan({
860
+ args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
861
+ plugins,
862
+ serializers: plugins.map((p) => p.serializer),
863
+ });
864
+ expect(exit).toBe(0);
865
+ const plan = JSON.parse(stdoutBuf.join("\n"));
866
+ const row = plan.entries.find((e: { name: string }) => e.name === "dbMigrated");
867
+ expect(row).toMatchObject({ action: "effect", effect: "db-migrate", effectReason: "receipt-absent", lexicon: "aws" });
868
+ expect(plan.entries.filter((e: { name: string; action: string }) => e.name === "dbMigrated")).toHaveLength(1);
869
+ });
870
+
871
+ test("a live receipt carrying the existence marker is a clean noop, and the render says nothing fires", async () => {
872
+ buildMock.mockResolvedValue(buildWithReceipt("aws"));
873
+ const plugins: LexiconPlugin[] = [
874
+ createMockPlugin({
875
+ name: "aws",
876
+ emulator: awsEmulatorStub,
877
+ describeResources: async () => ({
878
+ observation: "v1" as const,
879
+ resources: {
880
+ bucket: meta(),
881
+ dbMigrated: meta({ type: "aws::Receipt", attributes: { value: "chant.effect-receipt:exists" } }),
882
+ },
883
+ }),
884
+ }),
885
+ ];
886
+ const exit = await runLifecyclePlan({
887
+ args: makeArgs({ path: "plan", extraPositional: "prod" }),
888
+ plugins,
889
+ serializers: plugins.map((p) => p.serializer),
890
+ });
891
+ expect(exit).toBe(0);
892
+ expect(stdoutBuf.join("\n")).toContain("0 effect");
893
+ expect(stdoutBuf.join("\n")).not.toContain("effect will fire");
894
+ });
895
+
896
+ test("a stale receipt renders effect will fire", async () => {
897
+ buildMock.mockResolvedValue(buildWithReceipt("aws"));
898
+ const plugins: LexiconPlugin[] = [
899
+ createMockPlugin({
900
+ name: "aws",
901
+ emulator: awsEmulatorStub,
902
+ describeResources: async () => ({
903
+ observation: "v1" as const,
904
+ resources: {
905
+ bucket: meta(),
906
+ dbMigrated: meta({ type: "aws::Receipt", attributes: { value: "something-else" } }),
907
+ },
908
+ }),
909
+ }),
910
+ ];
911
+ const exit = await runLifecyclePlan({
912
+ args: makeArgs({ path: "plan", extraPositional: "prod" }),
913
+ plugins,
914
+ serializers: plugins.map((p) => p.serializer),
915
+ });
916
+ expect(exit).toBe(0);
917
+ expect(stdoutBuf.join("\n")).toContain("effect will fire: db-migrate");
918
+ });
919
+
920
+ test("a receipt no loaded lexicon observes is unobserved, loudly", async () => {
921
+ buildMock.mockResolvedValue(buildWithReceipt("chant"));
922
+ const plugins: LexiconPlugin[] = [
923
+ createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({ bucket: meta() }) }),
924
+ ];
925
+ const exit = await runLifecyclePlan({
926
+ args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
927
+ plugins,
928
+ serializers: plugins.map((p) => p.serializer),
929
+ });
930
+ expect(exit).toBe(0);
931
+ const plan = JSON.parse(stdoutBuf.join("\n"));
932
+ const row = plan.entries.find((e: { name: string }) => e.name === "dbMigrated");
933
+ expect(row).toMatchObject({ action: "unobserved", unobservedReason: "unsupported-kind" });
934
+ expect(stderrBuf.join("\n")).toContain("could not be observed");
935
+ });
936
+ });
937
+
824
938
  // #1166 — plan is always a live read (no `--live` flag of its own), so a
825
939
  // declared environment endpoint applies here exactly as it does for
826
940
  // `chant graph --live` / `chant lifecycle diff --live`.
@@ -1279,7 +1393,7 @@ describe("runLifecycleTeardown (#1222 — plan only)", () => {
1279
1393
  const out = stdoutBuf.join("\n");
1280
1394
  expect(out).toContain("Outcomes:");
1281
1395
  expect(out).toContain("deleted");
1282
- expect(out).toContain("1 deleted, 0 failed, 0 not prunable, 0 skipped");
1396
+ expect(out).toContain("1 deleted, 0 failed, 0 not prunable, 0 retained, 0 skipped");
1283
1397
  expect(out).not.toContain("plan only");
1284
1398
  });
1285
1399
 
@@ -3,8 +3,9 @@ import { commandBuildParams } from "../build-params-cli";
3
3
  import { build } from "../../build";
4
4
  import { takeSnapshot } from "../../lifecycle/snapshot";
5
5
  import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
6
- import { deepDiffForLexicon } from "../../lifecycle/deep-observe";
6
+ import { deepDiffForLexicon, type DeclaredEntities } from "../../lifecycle/deep-observe";
7
7
  import { countPropertyDrift, type DeepDiffResult } from "../../lifecycle/deep-diff";
8
+ import { describePathOrigin, getPathProvenance } from "../../provenance";
8
9
  import {
9
10
  acceptDeviations,
10
11
  baselineForLexicon,
@@ -18,6 +19,8 @@ import {
18
19
  import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
19
20
  import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
20
21
  import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
22
+ import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
23
+ import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
21
24
  import {
22
25
  formatUnobserved,
23
26
  mergeObservations,
@@ -53,7 +56,7 @@ import type { ChantConfig } from "../../config";
53
56
  * then `config.sourceDir`, then "." (the root). Snapshot/diff/plan all use this
54
57
  * so their build digests stay consistent.
55
58
  */
56
- function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
59
+ export function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
57
60
  return resolve(args.src ?? config.sourceDir ?? ".");
58
61
  }
59
62
 
@@ -358,6 +361,10 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
358
361
  // committed set. Absent is the normal state (nothing accepted yet).
359
362
  const baseline = args.live ? await readObservationBaseline(environment) : null;
360
363
  const accepted: Record<string, DeviationToAccept[]> = {};
364
+ // Effect receipts across every target's build (#1833) — `--update-baseline`
365
+ // refuses to accept drift on them (the effect step is their only writer),
366
+ // and recognition is marker-based so materialized lexicon rows are caught.
367
+ const receiptEntities = new Set<string>();
361
368
 
362
369
  // #1166 — an environment can declare its own endpoint (a local emulator like
363
370
  // Floci), so `--live` is self-sufficient even when the ambient shell never
@@ -430,6 +437,9 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
430
437
  for (const [lexicon, deviations] of Object.entries(r.toAccept)) {
431
438
  (accepted[lexicon] ??= []).push(...deviations);
432
439
  }
440
+ if (args.updateBaseline) {
441
+ for (const name of collectEffectReceipts(buildResult.entities).keys()) receiptEntities.add(name);
442
+ }
433
443
  if (json) {
434
444
  if (target.stack) perStackJson[target.stack] = r.byLexicon;
435
445
  else combinedLexiconsJson = r.byLexicon;
@@ -443,7 +453,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
443
453
  // accepted, so it stops re-alerting. Runs before the summary lines so the
444
454
  // "no drift" verdict below still describes the run that produced it.
445
455
  if (args.live && args.updateBaseline) {
446
- await recordAcceptedBaseline(environment, baseline, accepted, json);
456
+ await recordAcceptedBaseline(environment, baseline, accepted, json, receiptEntities);
447
457
  }
448
458
 
449
459
  if (args.live) {
@@ -495,6 +505,7 @@ async function recordAcceptedBaseline(
495
505
  existing: ObservationBaseline | null,
496
506
  accepted: Record<string, DeviationToAccept[]>,
497
507
  json: boolean,
508
+ receipts?: ReadonlySet<string>,
498
509
  ): Promise<void> {
499
510
  const total = Object.values(accepted).reduce((n, d) => n + d.length, 0);
500
511
  if (total === 0) {
@@ -505,11 +516,14 @@ async function recordAcceptedBaseline(
505
516
  }
506
517
  return;
507
518
  }
508
- let next = existing ?? emptyBaseline(environment);
509
- for (const [lexicon, deviations] of Object.entries(accepted)) {
510
- next = acceptDeviations(next, lexicon, deviations);
511
- }
512
519
  try {
520
+ // `acceptDeviations` throws on an effect-receipt deviation (#1833) —
521
+ // inside the try so the refusal reaches the operator as a formatted
522
+ // error, with no baseline written at all.
523
+ let next = existing ?? emptyBaseline(environment);
524
+ for (const [lexicon, deviations] of Object.entries(accepted)) {
525
+ next = acceptDeviations(next, lexicon, deviations, { receipts });
526
+ }
513
527
  await writeObservationBaseline(next);
514
528
  const pushed = await pushLifecycle();
515
529
  if (!json) {
@@ -520,7 +534,7 @@ async function recordAcceptedBaseline(
520
534
  }
521
535
  } catch (err) {
522
536
  console.error(formatError({
523
- message: `--update-baseline: could not write the baseline — ${err instanceof Error ? err.message : String(err)}`,
537
+ message: `--update-baseline: baseline not updated — ${err instanceof Error ? err.message : String(err)}`,
524
538
  }));
525
539
  }
526
540
  }
@@ -790,13 +804,17 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
790
804
  // declared name the reader can never resolve reads as missing (see
791
805
  // lifecycle/observe.ts).
792
806
  const declared = new Set<string>();
793
- const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
807
+ const entities: DeclaredEntities = new Map();
794
808
  for (const [name, entity] of args.buildResult.entities) {
795
809
  if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
796
810
  declared.add(name);
811
+ // Path origins are carried across explicitly (#1443): this map is plain
812
+ // objects, and the symbol-keyed provenance channel does not survive it.
813
+ const pathOrigins = getPathProvenance(entity);
797
814
  entities.set(name, {
798
815
  entityType: entity.entityType,
799
816
  props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
817
+ ...(pathOrigins ? { pathOrigins } : {}),
800
818
  });
801
819
  }
802
820
  }
@@ -951,7 +969,10 @@ function renderDeepDiff(lexiconName: string, deep: DeepDiffResult): void {
951
969
  const declared = "declared" in change ? formatValue(change.declared) : "<undeclared>";
952
970
  const live = "live" in change ? formatValue(change.live) : "<absent>";
953
971
  const baseline = "baseline" in change ? ` [accepted: ${formatValue(change.baseline)}]` : "";
954
- console.log(` ${change.path}: ${declared} → ${live}${baseline}`);
972
+ // Both sides of the attribution, where each is known (#1443/#1189).
973
+ const from = change.origin ? ` [from: ${describePathOrigin(change.origin)}]` : "";
974
+ const owner = change.owner ? ` [owner: ${change.owner}]` : "";
975
+ console.log(` ${change.path}: ${declared} → ${live}${baseline}${from}${owner}`);
955
976
  }
956
977
  }
957
978
  }
@@ -1126,6 +1147,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1126
1147
  const merged: ChangeSet = { env: environment, entries: [] };
1127
1148
  let checked = 0;
1128
1149
 
1150
+ // Effect receipts (#1832): declared, diffed, and observed like any resource,
1151
+ // but observe-only to the generic apply path — the plan compares live value
1152
+ // to resolved expectation and proposes the fire, never a create/update.
1153
+ const receipts = collectEffectReceipts(buildResult.entities);
1154
+ const receiptReadings = new Map<string, ReceiptReading>();
1155
+ // Every lexicon's observed resources merged, for resolving a receipt's
1156
+ // reference inputs against observed values / stack outputs at plan time.
1157
+ const allObservedResources: Record<string, ResourceMetadata> = {};
1158
+
1129
1159
  // #1166 — same self-sufficiency as `chant graph --live`: an environment can
1130
1160
  // declare its own endpoint, applied here unless the ambient shell already
1131
1161
  // set it. `chant lifecycle plan` is always a live read (no `--live` flag of
@@ -1156,11 +1186,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1156
1186
  const declared = new Set<string>();
1157
1187
  const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
1158
1188
  for (const [name, entity] of buildResult.entities) {
1159
- if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
1189
+ // A receipt has no `props` payload of its own but is declared, diffed,
1190
+ // and observed like any resource (#1832) — it joins the declared axis
1191
+ // so its lexicon's observation can confirm presence or absence.
1192
+ if (entity.lexicon === lexiconName && (isResourceDeclarable(entity) || isEffectReceipt(entity))) {
1160
1193
  declared.add(name);
1161
1194
  entities.set(name, {
1162
1195
  entityType: entity.entityType,
1163
- props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
1196
+ props: (isResourceDeclarable(entity) && entity.props != null ? entity.props : {}) as Record<string, unknown>,
1164
1197
  });
1165
1198
  }
1166
1199
  }
@@ -1185,6 +1218,37 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1185
1218
  ...(args.namespace ? { namespace: args.namespace } : {}),
1186
1219
  });
1187
1220
 
1221
+ Object.assign(allObservedResources, observed.resources);
1222
+ for (const [receiptName, receipt] of receipts) {
1223
+ if (receipt.lexicon !== lexiconName) continue;
1224
+ const live = observed.resources[receiptName];
1225
+ const hole = observed.unobserved[receiptName];
1226
+ if (live) {
1227
+ receiptReadings.set(receiptName, {
1228
+ observed: true,
1229
+ present: true,
1230
+ value: readReceiptValue(live.attributes),
1231
+ type: live.type,
1232
+ ...(live.physicalId ? { physicalId: live.physicalId } : {}),
1233
+ lexicon: lexiconName,
1234
+ });
1235
+ } else if (hole) {
1236
+ receiptReadings.set(receiptName, {
1237
+ observed: false,
1238
+ present: false,
1239
+ lexicon: lexiconName,
1240
+ ...(hole.type ? { type: hole.type } : {}),
1241
+ unobservedReason: hole.reason,
1242
+ ...(hole.detail ? { unobservedDetail: hole.detail } : {}),
1243
+ });
1244
+ } else {
1245
+ // Neither returned nor named unobserved: the lexicon looked and
1246
+ // confirmed the receipt absent — the same claim the change set
1247
+ // reads off a bare observation (#1089).
1248
+ receiptReadings.set(receiptName, { observed: true, present: false, lexicon: lexiconName });
1249
+ }
1250
+ }
1251
+
1188
1252
  const content = await readSnapshot(environment, lexiconName);
1189
1253
  const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
1190
1254
 
@@ -1214,6 +1278,16 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1214
1278
  return 1;
1215
1279
  }
1216
1280
 
1281
+ // Receipt classification (#1832): live value vs resolved expectation.
1282
+ // Absent/stale proposes the fire; unobservable is a loud hole; whatever the
1283
+ // generic classification proposed for a receipt (a create, a delete) is
1284
+ // replaced — the effect step is the sole writer, and a receipt is never a
1285
+ // prune candidate.
1286
+ if (receipts.size > 0) {
1287
+ const receiptEntries = planReceipts(receipts, receiptReadings, observedValueResolver(allObservedResources));
1288
+ mergeReceiptEntries(merged, receipts, receiptEntries);
1289
+ }
1290
+
1217
1291
  merged.entries.sort((a, b) => a.name.localeCompare(b.name));
1218
1292
 
1219
1293
  // Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
@@ -1250,7 +1324,7 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1250
1324
  * with the requested env identity. Stateless — live markers only, no build, no
1251
1325
  * snapshot. Without `--yes` nothing is deleted; with it the planned set is
1252
1326
  * executed per lexicon with one bounded retry pass over failures, and every
1253
- * candidate's outcome is reported (deleted / failed / not-prunable / skipped —
1327
+ * candidate's outcome is reported (deleted / failed / not-prunable / retained / skipped —
1254
1328
  * never silence). A production-like environment name additionally requires an
1255
1329
  * interactive confirmation, or `--confirm-prod` non-interactively.
1256
1330
  */
@@ -1410,10 +1484,11 @@ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number>
1410
1484
  }
1411
1485
  }
1412
1486
 
1413
- const counts = { deleted: 0, failed: 0, "not-prunable": 0, skipped: 0 };
1487
+ const counts = { deleted: 0, failed: 0, "not-prunable": 0, retained: 0, skipped: 0 };
1414
1488
  for (const o of report.outcomes) counts[o.outcome]++;
1415
1489
  console.log(
1416
- `\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ${counts.skipped} skipped`,
1490
+ `\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ` +
1491
+ `${counts.retained} retained, ${counts.skipped} skipped`,
1417
1492
  );
1418
1493
 
1419
1494
  if (report.unimplemented.length > 0) {
@@ -1421,6 +1496,12 @@ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number>
1421
1496
  message: `Not executed (no teardown execution in these lexicons yet): ${report.unimplemented.join(", ")} — their candidates are reported as skipped, not deleted.`,
1422
1497
  }));
1423
1498
  }
1499
+ if (counts.retained > 0) {
1500
+ console.error(formatWarning({
1501
+ message: `${counts.retained} resource(s) retained — owned by this env but deliberately kept (generated-once secrets are never swept). ` +
1502
+ `The environment is NOT clean while they exist; delete them explicitly (e.g. kubectl delete) if you mean to.`,
1503
+ }));
1504
+ }
1424
1505
  if (plan.holes.length > 0) {
1425
1506
  console.error(formatWarning({
1426
1507
  message: "This teardown ran over an incomplete plan (holes above) — the environment cannot be called clean.",
@@ -0,0 +1,255 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+
4
+ const discoverConvergeOpsMock = vi.fn();
5
+ const runOperatorRoundMock = vi.fn();
6
+ const runOperatorForeverMock = vi.fn();
7
+ const discoverOpsMock = vi.fn();
8
+ const loadActivitiesMock = vi.fn();
9
+ const loadProfilesMock = vi.fn();
10
+ const loadChantConfigMock = vi.fn();
11
+ const readLeaseMock = vi.fn();
12
+ const readConvergeLedgerMock = vi.fn();
13
+ const appendGateResolutionMock = vi.fn();
14
+ const readGateResolutionsMock = vi.fn();
15
+ const pushLifecycleMock = vi.fn();
16
+
17
+ vi.mock("../../op/operator", async () => {
18
+ const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
19
+ return {
20
+ ...actual,
21
+ discoverConvergeOps: (...args: unknown[]) => discoverConvergeOpsMock(...args),
22
+ runOperatorRound: (...args: unknown[]) => runOperatorRoundMock(...args),
23
+ runOperatorForever: (...args: unknown[]) => runOperatorForeverMock(...args),
24
+ };
25
+ });
26
+ vi.mock("../../op/discover", () => ({ discoverOps: () => discoverOpsMock() }));
27
+ vi.mock("../../op/activity-registry", () => ({
28
+ loadActivities: (...args: unknown[]) => loadActivitiesMock(...args),
29
+ loadProfiles: (...args: unknown[]) => loadProfilesMock(...args),
30
+ }));
31
+ vi.mock("../../config", async () => {
32
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
33
+ return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
34
+ });
35
+ vi.mock("../../lifecycle/lease", async () => {
36
+ const actual = await vi.importActual<typeof import("../../lifecycle/lease")>("../../lifecycle/lease");
37
+ return { ...actual, readLease: (...args: unknown[]) => readLeaseMock(...args) };
38
+ });
39
+ vi.mock("../../lifecycle/converge-ledger", async () => {
40
+ const actual = await vi.importActual<typeof import("../../lifecycle/converge-ledger")>("../../lifecycle/converge-ledger");
41
+ return { ...actual, readConvergeLedger: (...args: unknown[]) => readConvergeLedgerMock(...args) };
42
+ });
43
+ vi.mock("../../lifecycle/gate-ledger", async () => {
44
+ const actual = await vi.importActual<typeof import("../../lifecycle/gate-ledger")>("../../lifecycle/gate-ledger");
45
+ return {
46
+ ...actual,
47
+ appendGateResolution: (...args: unknown[]) => appendGateResolutionMock(...args),
48
+ readGateResolutions: (...args: unknown[]) => readGateResolutionsMock(...args),
49
+ };
50
+ });
51
+ vi.mock("../../lifecycle/git", async () => {
52
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
53
+ return { ...actual, pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args) };
54
+ });
55
+
56
+ // Imported after the mocks above are registered.
57
+ const { runOperator, runOperatorStatus, runApprove } = await import("./operator");
58
+
59
+ function ctx(args: Partial<ParsedArgs>) {
60
+ return {
61
+ args: { command: "", path: ".", format: "", fix: false, watch: false, verbose: false, help: false, live: false, ...args } as ParsedArgs,
62
+ plugins: [],
63
+ serializers: [],
64
+ };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ loadChantConfigMock.mockResolvedValue({ config: { lexicons: [] } });
70
+ loadActivitiesMock.mockResolvedValue(new Map());
71
+ loadProfilesMock.mockResolvedValue({});
72
+ discoverOpsMock.mockResolvedValue({ ops: new Map([["fountain-apply", {}]]), errors: [] });
73
+ pushLifecycleMock.mockResolvedValue(true);
74
+ });
75
+
76
+ describe("runOperator", () => {
77
+ test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
78
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
79
+ const code = await runOperator(ctx({}));
80
+ expect(code).toBe(0);
81
+ expect(runOperatorRoundMock).not.toHaveBeenCalled();
82
+ expect(runOperatorForeverMock).not.toHaveBeenCalled();
83
+ });
84
+
85
+ test("--once runs exactly one round and exits 0 when nothing failed", async () => {
86
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
87
+ runOperatorRoundMock.mockResolvedValue([{ kind: "ticked", op: "staging-converge", env: "staging", result: { op: "staging-converge", records: [], totalMs: 1, ok: true } }]);
88
+
89
+ const code = await runOperator(ctx({ once: true }));
90
+ expect(code).toBe(0);
91
+ expect(runOperatorRoundMock).toHaveBeenCalledTimes(1);
92
+ expect(runOperatorForeverMock).not.toHaveBeenCalled();
93
+ });
94
+
95
+ test("--once exits 1 when any op's tick failed this round", async () => {
96
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
97
+ runOperatorRoundMock.mockResolvedValue([{ kind: "tick-failed", op: "staging-converge", env: "staging", error: "boom" }]);
98
+
99
+ const code = await runOperator(ctx({ once: true }));
100
+ expect(code).toBe(1);
101
+ });
102
+
103
+ test("without --once, runs the daemon loop (runOperatorForever) and exits 0 when it returns", async () => {
104
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
105
+ runOperatorForeverMock.mockResolvedValue(undefined);
106
+
107
+ const code = await runOperator(ctx({}));
108
+ expect(code).toBe(0);
109
+ expect(runOperatorForeverMock).toHaveBeenCalledTimes(1);
110
+ expect(runOperatorRoundMock).not.toHaveBeenCalled();
111
+ });
112
+
113
+ test("--interval and --lease-ttl are parsed as durations and threaded through", async () => {
114
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
115
+ runOperatorForeverMock.mockResolvedValue(undefined);
116
+
117
+ await runOperator(ctx({ interval: "30s", leaseTtl: "2m" }));
118
+ const passed = runOperatorForeverMock.mock.calls[0][0];
119
+ expect(passed.intervalMs).toBe(30_000);
120
+ expect(passed.leaseTtlMs).toBe(120_000);
121
+ });
122
+
123
+ test("--env is passed through to discovery and the round/loop", async () => {
124
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "prod-converge" } }], errors: [] });
125
+ runOperatorRoundMock.mockResolvedValue([]);
126
+
127
+ await runOperator(ctx({ once: true, env: "prod" }));
128
+ expect(discoverConvergeOpsMock).toHaveBeenCalledWith({ env: "prod" });
129
+ expect(runOperatorRoundMock.mock.calls[0][0].env).toBe("prod");
130
+ });
131
+
132
+ test("a failure loading activities is reported and exits 1", async () => {
133
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
134
+ loadActivitiesMock.mockRejectedValue(new Error("no activities registered"));
135
+
136
+ const code = await runOperator(ctx({ once: true }));
137
+ expect(code).toBe(1);
138
+ });
139
+ });
140
+
141
+ describe("runOperatorStatus", () => {
142
+ test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
143
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
144
+ const code = await runOperatorStatus(ctx({}));
145
+ expect(code).toBe(0);
146
+ });
147
+
148
+ test("--json emits one row per discovered ConvergeOp with last tick, lease, and pending gates", async () => {
149
+ discoverConvergeOpsMock.mockResolvedValue({
150
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
151
+ errors: [],
152
+ });
153
+ readConvergeLedgerMock.mockResolvedValue({
154
+ records: [
155
+ {
156
+ version: 1,
157
+ op: "staging-converge",
158
+ env: "staging",
159
+ timestamp: "2026-01-01T00:00:00.000Z",
160
+ firedRuleIds: ["drift-apply"],
161
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
162
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
163
+ log: "converge(staging): ...",
164
+ },
165
+ ],
166
+ malformed: 0,
167
+ });
168
+ readLeaseMock.mockResolvedValue({ sha: "abc", record: { op: "staging-converge", holder: "op-a", token: "t1", acquiredAt: "x", expiresAt: "y" } });
169
+ readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 }); // no resolution yet — still pending
170
+
171
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
172
+ const code = await runOperatorStatus(ctx({ json: true }));
173
+ expect(code).toBe(0);
174
+
175
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
176
+ expect(printed).toHaveLength(1);
177
+ expect(printed[0].op).toBe("staging-converge");
178
+ expect(printed[0].lease).toEqual({ holder: "op-a", expiresAt: "y" });
179
+ expect(printed[0].pendingGates).toEqual([{ rule: "drift-apply", op: "fountain-apply", gate: "rollout-gate" }]);
180
+ logSpy.mockRestore();
181
+ });
182
+
183
+ test("a gate resolved after the tick that recorded it is no longer pending", async () => {
184
+ discoverConvergeOpsMock.mockResolvedValue({
185
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
186
+ errors: [],
187
+ });
188
+ readConvergeLedgerMock.mockResolvedValue({
189
+ records: [
190
+ {
191
+ version: 1,
192
+ op: "staging-converge",
193
+ env: "staging",
194
+ timestamp: "2026-01-01T00:00:00.000Z",
195
+ firedRuleIds: ["drift-apply"],
196
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
197
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
198
+ log: "converge(staging): ...",
199
+ },
200
+ ],
201
+ malformed: 0,
202
+ });
203
+ readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
204
+ readGateResolutionsMock.mockResolvedValue({
205
+ records: [{ version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z" }],
206
+ malformed: 0,
207
+ });
208
+
209
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
210
+ await runOperatorStatus(ctx({ json: true }));
211
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
212
+ expect(printed[0].pendingGates).toEqual([]);
213
+ logSpy.mockRestore();
214
+ });
215
+ });
216
+
217
+ describe("runApprove", () => {
218
+ test("requires both <op> and <gate>", async () => {
219
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
220
+ expect(await runApprove(ctx({ path: "." }))).toBe(1);
221
+ expect(await runApprove(ctx({ path: "fountain-apply" }))).toBe(1);
222
+ errSpy.mockRestore();
223
+ });
224
+
225
+ test("appends a gate-resolution record and pushes, resolving --actor over env fallbacks", async () => {
226
+ appendGateResolutionMock.mockResolvedValue({
227
+ commit: "sha",
228
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
229
+ });
230
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
231
+
232
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", note: "https://pr/1" }));
233
+
234
+ expect(code).toBe(0);
235
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(
236
+ expect.objectContaining({ op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", note: "https://pr/1" }),
237
+ );
238
+ expect(pushLifecycleMock).toHaveBeenCalled();
239
+ errSpy.mockRestore();
240
+ });
241
+
242
+ test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
243
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
244
+ appendGateResolutionMock.mockResolvedValue({
245
+ commit: "sha",
246
+ record: { version: 1, op: "unknown-op", gate: "g", resolvedBy: "unknown", timestamp: "2026-01-01T00:00:00.000Z" },
247
+ });
248
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
249
+
250
+ const code = await runApprove(ctx({ path: "unknown-op", extraPositional: "g" }));
251
+ expect(code).toBe(0);
252
+ expect(appendGateResolutionMock).toHaveBeenCalled();
253
+ errSpy.mockRestore();
254
+ });
255
+ });