@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
@@ -0,0 +1,343 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { withTestDir } from "@intentius/chant-test-utils";
3
+ import { spawnSync } from "node:child_process";
4
+ import { writeFileSync, mkdirSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import {
8
+ acquireLease,
9
+ releaseLease,
10
+ readLease,
11
+ stillHoldsLease,
12
+ currentHolderId,
13
+ leaseRef,
14
+ LEASE_REF_PREFIX,
15
+ } from "./lease";
16
+ import { writeBlob, updateRefCAS, readRefSha, pushRef, StaleLockError, RefCASConflictError } from "./git";
17
+
18
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
19
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
20
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
21
+ }
22
+
23
+ async function initRepo(dir: string): Promise<void> {
24
+ git(["init", "-q", "-b", "main"], dir);
25
+ git(["config", "user.email", "test@chant.dev"], dir);
26
+ git(["config", "user.name", "Test"], dir);
27
+ writeFileSync(join(dir, "README.md"), "fixture\n");
28
+ git(["add", "README.md"], dir);
29
+ git(["commit", "-q", "-m", "init"], dir);
30
+ }
31
+
32
+ describe("lifecycle/lease", () => {
33
+ test("leaseRef namespaces under refs/chant/lease/, distinct from the lifecycle branch", () => {
34
+ expect(leaseRef("fountain-converge")).toBe(`${LEASE_REF_PREFIX}fountain-converge`);
35
+ expect(leaseRef("fountain-converge")).not.toMatch(/^refs\/heads\//);
36
+ });
37
+
38
+ test("currentHolderId is stable-shaped (host:pid:random) and unique per call", () => {
39
+ const a = currentHolderId();
40
+ const b = currentHolderId();
41
+ expect(a).toMatch(/^.+:\d+:[0-9a-f]{8}$/);
42
+ expect(a).not.toBe(b);
43
+ });
44
+
45
+ test("readLease on a never-acquired op returns no record", async () => {
46
+ await withTestDir(async (dir) => {
47
+ await initRepo(dir);
48
+ const { sha, record } = await readLease("no-such-op", { cwd: dir });
49
+ expect(sha).toBeNull();
50
+ expect(record).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ describe("acquireLease — single-writer fencing (#1485)", () => {
55
+ test("first acquire succeeds and mints a fresh token", async () => {
56
+ await withTestDir(async (dir) => {
57
+ await initRepo(dir);
58
+ const result = await acquireLease("fountain-converge", "holder-a", { cwd: dir });
59
+ expect(result.acquired).toBe(true);
60
+ expect(result.lease?.holder).toBe("holder-a");
61
+ expect(result.lease?.token).toMatch(/^[0-9a-f-]{36}$/);
62
+ });
63
+ });
64
+
65
+ test("a second holder cannot acquire a live, unexpired lease — the CAS loser stops, not queues", async () => {
66
+ await withTestDir(async (dir) => {
67
+ await initRepo(dir);
68
+ const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
69
+ expect(first.acquired).toBe(true);
70
+
71
+ const second = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 60_000 });
72
+ expect(second.acquired).toBe(false);
73
+ expect(second.heldBy?.holder).toBe("holder-a");
74
+ expect(second.heldBy?.token).toBe(first.lease?.token);
75
+ });
76
+ });
77
+
78
+ test("the same holder renewing keeps its token but pushes out expiresAt", async () => {
79
+ await withTestDir(async (dir) => {
80
+ await initRepo(dir);
81
+ const t0 = new Date("2026-01-01T00:00:00.000Z");
82
+ const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000, now: () => t0 });
83
+
84
+ const t1 = new Date("2026-01-01T00:00:30.000Z");
85
+ const renewed = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000, now: () => t1 });
86
+
87
+ expect(renewed.acquired).toBe(true);
88
+ expect(renewed.lease?.token).toBe(first.lease?.token);
89
+ expect(renewed.lease?.acquiredAt).toBe(first.lease?.acquiredAt);
90
+ expect(new Date(renewed.lease!.expiresAt).getTime()).toBeGreaterThan(new Date(first.lease!.expiresAt).getTime());
91
+ });
92
+ });
93
+
94
+ test("a new holder can acquire once the previous lease has expired, and mints a new token", async () => {
95
+ await withTestDir(async (dir) => {
96
+ await initRepo(dir);
97
+ const t0 = new Date("2026-01-01T00:00:00.000Z");
98
+ const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 1_000, now: () => t0 });
99
+ expect(first.acquired).toBe(true);
100
+
101
+ // holder-a crashes and never renews; holder-b tries after the TTL passes.
102
+ const tExpired = new Date("2026-01-01T00:00:05.000Z");
103
+ const second = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 1_000, now: () => tExpired });
104
+
105
+ expect(second.acquired).toBe(true);
106
+ expect(second.lease?.holder).toBe("holder-b");
107
+ expect(second.lease?.token).not.toBe(first.lease?.token);
108
+ });
109
+ });
110
+
111
+ // ── #1959 finding 2 ──────────────────────────────────────────────────
112
+ //
113
+ // Before this fix, `updateRefCAS` turned ANY nonzero git exit —
114
+ // including a stale `.lock` file left by a killed process — into
115
+ // RefCASConflictError, and `acquireLease` folded that straight into
116
+ // "someone else holds it" (`heldBy`). That's wrong: a stale lock isn't
117
+ // contention, it's wreckage from the exact crash this feature must
118
+ // recover from, and TTL expiry never fixes it (the ref update itself
119
+ // can't land while the lock file sits there) — so a project would get
120
+ // permanently, silently stuck acquiring that op's lease.
121
+ test("a stale .lock file surfaces as StaleLockError, NOT as \"lease held by someone else\" (#1959 finding 2)", async () => {
122
+ await withTestDir(async (dir) => {
123
+ await initRepo(dir);
124
+ // Acquire once for real, so the ref exists.
125
+ const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
126
+ expect(first.acquired).toBe(true);
127
+
128
+ // Simulate holder-a's own process getting killed mid-renewal: git's
129
+ // lockfile-then-rename never completed, so the `.lock` file it
130
+ // created is still there.
131
+ mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
132
+ writeFileSync(join(dir, ".git", "refs", "chant", "lease", "fountain-converge.lock"), "");
133
+
134
+ // The SAME holder trying to renew must actually attempt the write
135
+ // (it owns the lease, so acquireLease doesn't short-circuit before
136
+ // touching git) — and hit the lock file. It must not read this as
137
+ // "held by someone else": it must throw a diagnosable
138
+ // StaleLockError, distinct from the ordinary
139
+ // `{ acquired: false, heldBy }` contention outcome.
140
+ const err = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 }).catch((e) => e);
141
+ expect(err).toBeInstanceOf(StaleLockError);
142
+ expect(err).not.toBeInstanceOf(RefCASConflictError);
143
+ expect((err as StaleLockError).message).toContain("fountain-converge.lock");
144
+ });
145
+ });
146
+
147
+ test("a stale .lock file also blocks a FRESH holder once the previous lease has expired — surfaced as StaleLockError, not misread as expiry succeeding or as contention (#1959 finding 2)", async () => {
148
+ await withTestDir(async (dir) => {
149
+ await initRepo(dir);
150
+ const t0 = new Date("2026-01-01T00:00:00.000Z");
151
+ const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 1_000, now: () => t0 });
152
+ expect(first.acquired).toBe(true);
153
+
154
+ mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
155
+ writeFileSync(join(dir, ".git", "refs", "chant", "lease", "fountain-converge.lock"), "");
156
+
157
+ // holder-a's lease has since expired — a normal crash-recovery
158
+ // acquire should be able to proceed to a CAS write here (nothing is
159
+ // "held" any more), but the same leftover lock file still blocks
160
+ // the write itself.
161
+ const tExpired = new Date("2026-01-01T00:00:05.000Z");
162
+ const err = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 1_000, now: () => tExpired }).catch(
163
+ (e) => e,
164
+ );
165
+ expect(err).toBeInstanceOf(StaleLockError);
166
+ expect(err).not.toBeInstanceOf(RefCASConflictError);
167
+ });
168
+ });
169
+ });
170
+
171
+ describe("stillHoldsLease — the fencing check a tick uses before trusting its own work", () => {
172
+ test("true while the same holder+token is still live", async () => {
173
+ await withTestDir(async (dir) => {
174
+ await initRepo(dir);
175
+ const acquired = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
176
+ expect(await stillHoldsLease("fountain-converge", "holder-a", acquired.lease!.token, { cwd: dir })).toBe(true);
177
+ });
178
+ });
179
+
180
+ test("false once another holder has taken over (stale token refused)", async () => {
181
+ await withTestDir(async (dir) => {
182
+ await initRepo(dir);
183
+ const t0 = new Date("2026-01-01T00:00:00.000Z");
184
+ const a = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 1_000, now: () => t0 });
185
+
186
+ const tExpired = new Date("2026-01-01T00:00:05.000Z");
187
+ await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 1_000, now: () => tExpired });
188
+
189
+ // holder-a's tick, still carrying its now-stale token, checks in after
190
+ // holder-b has already reclaimed the lease.
191
+ expect(await stillHoldsLease("fountain-converge", "holder-a", a.lease!.token, { cwd: dir })).toBe(false);
192
+ });
193
+ });
194
+
195
+ test("false for an op with no lease at all", async () => {
196
+ await withTestDir(async (dir) => {
197
+ await initRepo(dir);
198
+ expect(await stillHoldsLease("no-such-op", "holder-a", "any-token", { cwd: dir })).toBe(false);
199
+ });
200
+ });
201
+ });
202
+
203
+ describe("releaseLease", () => {
204
+ test("releases when holder+token match, and the lease becomes acquirable again immediately", async () => {
205
+ await withTestDir(async (dir) => {
206
+ await initRepo(dir);
207
+ const acquired = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
208
+ expect(await releaseLease("fountain-converge", "holder-a", acquired.lease!.token, { cwd: dir })).toBe(true);
209
+
210
+ const { record } = await readLease("fountain-converge", { cwd: dir });
211
+ expect(record).toBeUndefined();
212
+
213
+ const second = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 60_000 });
214
+ expect(second.acquired).toBe(true);
215
+ });
216
+ });
217
+
218
+ test("refuses to release a lease held by someone else, or with a stale token — never drops another holder's lease", async () => {
219
+ await withTestDir(async (dir) => {
220
+ await initRepo(dir);
221
+ const acquired = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
222
+
223
+ expect(await releaseLease("fountain-converge", "holder-b", acquired.lease!.token, { cwd: dir })).toBe(false);
224
+ expect(await releaseLease("fountain-converge", "holder-a", "wrong-token", { cwd: dir })).toBe(false);
225
+
226
+ const { record } = await readLease("fountain-converge", { cwd: dir });
227
+ expect(record?.holder).toBe("holder-a");
228
+ });
229
+ });
230
+
231
+ test("releasing a non-existent lease is a harmless false, not a throw", async () => {
232
+ await withTestDir(async (dir) => {
233
+ await initRepo(dir);
234
+ expect(await releaseLease("no-such-op", "holder-a", "t", { cwd: dir })).toBe(false);
235
+ });
236
+ });
237
+ });
238
+
239
+ // ── Cross-machine contention (#1485 acceptance criterion: "two operators, one remote") ──
240
+
241
+ describe("two operators, one remote — the lease arbitrates across clones, not just within one", () => {
242
+ async function setupClonePair(): Promise<{ clonePath: string; remotePath: string; cleanup: () => Promise<void> }> {
243
+ const remotePath = join(tmpdir(), `chant-lease-remote-${Date.now()}-${Math.random()}`);
244
+ const clonePath = join(tmpdir(), `chant-lease-clone-${Date.now()}-${Math.random()}`);
245
+ const { mkdir, rm } = await import("node:fs/promises");
246
+ await mkdir(remotePath, { recursive: true });
247
+ git(["init", "-q", "--bare", "-b", "main"], remotePath);
248
+ git(["clone", "-q", remotePath, clonePath], tmpdir());
249
+ git(["config", "user.email", "test@chant.dev"], clonePath);
250
+ git(["config", "user.name", "Test"], clonePath);
251
+ writeFileSync(join(clonePath, "README.md"), "fixture\n");
252
+ git(["add", "README.md"], clonePath);
253
+ git(["commit", "-q", "-m", "init"], clonePath);
254
+ git(["push", "-q", "origin", "main"], clonePath);
255
+ return {
256
+ clonePath,
257
+ remotePath,
258
+ cleanup: async () => {
259
+ await rm(remotePath, { recursive: true, force: true });
260
+ await rm(clonePath, { recursive: true, force: true });
261
+ },
262
+ };
263
+ }
264
+
265
+ test("operator A acquires and pushes; operator B (a second clone) sees it as held and loses the race", async () => {
266
+ const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
267
+ const cloneB = join(tmpdir(), `chant-lease-clone-b-${Date.now()}-${Math.random()}`);
268
+ try {
269
+ git(["clone", "-q", remotePath, cloneB], tmpdir());
270
+ git(["config", "user.email", "test@chant.dev"], cloneB);
271
+ git(["config", "user.name", "Test"], cloneB);
272
+
273
+ const a = await acquireLease("fountain-converge", "operator-a", { cwd: cloneA, ttlMs: 60_000 });
274
+ expect(a.acquired).toBe(true);
275
+
276
+ // Operator B, on a different clone of the same remote, tries next —
277
+ // acquireLease fetches the ref first, so A's push is visible.
278
+ const b = await acquireLease("fountain-converge", "operator-b", { cwd: cloneB, ttlMs: 60_000 });
279
+ expect(b.acquired).toBe(false);
280
+ expect(b.heldBy?.holder).toBe("operator-a");
281
+ } finally {
282
+ await cleanup();
283
+ const { rm } = await import("node:fs/promises");
284
+ await rm(cloneB, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ // ── #1959 finding 3 ──────────────────────────────────────────────────
289
+ //
290
+ // `readLease` used to fetch the remote lease ref directly into the same
291
+ // local ref name (`+ref:ref`) before every read — including the read
292
+ // `chant operator status` does. That force-overwrites the local ref, so
293
+ // a concurrent `status` call landing in the exact acquire→push window
294
+ // (the local CAS write has landed, but the push to remote hasn't yet)
295
+ // would revert the just-acquired local lease back to whatever the
296
+ // remote still had — silently, in the SAME clone, with no other process
297
+ // racing at all. `readLease` now fetches into a side tracking ref
298
+ // instead, so the canonical local ref is never touched by a read.
299
+ test("a concurrent read in the SAME clone, during the acquire→push window, must not clobber the just-acquired local lease (#1959 finding 3)", async () => {
300
+ const { clonePath, remotePath, cleanup } = await setupClonePair();
301
+ try {
302
+ // An older, now-stale lease already sits on the remote (some earlier holder, long expired).
303
+ const stale = {
304
+ op: "fountain-converge",
305
+ holder: "old-holder",
306
+ token: "old-token",
307
+ acquiredAt: "2020-01-01T00:00:00.000Z",
308
+ expiresAt: "2020-01-01T00:05:00.000Z",
309
+ };
310
+ const staleBlobSha = await writeBlob(JSON.stringify(stale), { cwd: clonePath });
311
+ await updateRefCAS(leaseRef("fountain-converge"), staleBlobSha, null, { cwd: clonePath });
312
+ expect(await pushRef(leaseRef("fountain-converge"), { cwd: clonePath })).toBe(true);
313
+
314
+ // Simulate the local half of a fresh acquire that has landed the
315
+ // local CAS write but has NOT pushed yet — the exact acquire→push
316
+ // window this finding is about (acquireLease itself always pushes
317
+ // right after its own CAS write; this reproduces the in-between
318
+ // moment deterministically rather than racing real concurrency).
319
+ const fresh = {
320
+ op: "fountain-converge",
321
+ holder: "new-holder",
322
+ token: "new-token",
323
+ acquiredAt: new Date().toISOString(),
324
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
325
+ };
326
+ const freshBlobSha = await writeBlob(JSON.stringify(fresh), { cwd: clonePath });
327
+ await updateRefCAS(leaseRef("fountain-converge"), freshBlobSha, staleBlobSha, { cwd: clonePath });
328
+
329
+ // A concurrent `chant operator status` in the SAME clone reads the lease now.
330
+ const { record } = await readLease("fountain-converge", { cwd: clonePath });
331
+ expect(record?.holder).toBe("new-holder");
332
+ expect(record?.token).toBe("new-token");
333
+
334
+ // And the canonical local ref itself must still hold the
335
+ // freshly-acquired value — not force-reverted to the stale remote
336
+ // one readLease's own fetch pulled down.
337
+ expect(await readRefSha(leaseRef("fountain-converge"), { cwd: clonePath })).toBe(freshBlobSha);
338
+ } finally {
339
+ await cleanup();
340
+ }
341
+ });
342
+ });
343
+ });
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Operator lease (#1485, epic #1487) — single-writer coordination for a
3
+ * `ConvergeOp`'s tick loop, entirely over git. No new state store: the lease
4
+ * lives on a dedicated ref namespace (`refs/chant/lease/<op>`), separate
5
+ * from the `chant/lifecycle` orphan branch's own commit history, whose
6
+ * value IS a lease-record blob — no tree, no commit (./git.ts's
7
+ * `writeBlob`/`readBlobBySha`). Acquiring or renewing a lease is one
8
+ * `updateRefCAS` call (./git.ts): the caller reads the ref's current SHA,
9
+ * and the write only lands if the ref still points there. No `flock` file
10
+ * either — `git update-ref` is already an atomic local mutex (its own
11
+ * lockfile-then-rename), so a second local process racing the same acquire
12
+ * loses outright without any second locking mechanism to reason about.
13
+ *
14
+ * Cross-machine contention is settled by pushing/fetching this one ref
15
+ * through the project's remote (best-effort — see `acquireLease`). A
16
+ * project with no remote is single-machine by construction: the local CAS
17
+ * above is then the *entire* coordination story, which is exactly what
18
+ * issue #1485's open question 5 asks be stated loudly — `chant operator`'s
19
+ * own docs page says so explicitly; nothing here silently upgrades a
20
+ * remote-less lease to team-visible durability.
21
+ *
22
+ * The record's `token` is the fencing token the issue asks for: a fresh
23
+ * value is minted only when the lease actually changes hands (first
24
+ * acquire, or a re-acquire after the previous holder's lease expired or was
25
+ * released) — never on a same-holder renewal, so an in-flight tick's token
26
+ * stays valid across the operator loop's own heartbeats. `stillHoldsLease`
27
+ * is what a caller uses, right before trusting a finished tick's own work,
28
+ * to notice its token has since changed (the lease was stolen mid-tick,
29
+ * e.g. the process stalled past its TTL) — see `../op/operator.ts`'s tick
30
+ * loop for how that's handled: never a hard failure, since a converge tick
31
+ * is idempotent by design (it re-observes and re-derives everything), so a
32
+ * late, fenced-out write is redundant, not corrupting.
33
+ */
34
+ import { hostname } from "node:os";
35
+ import { randomUUID } from "node:crypto";
36
+ import { readRefSha, updateRefCAS, deleteRefCAS, writeBlob, readBlobBySha, pushRef, fetchRefInto, RefCASConflictError } from "./git";
37
+
38
+ export const LEASE_REF_PREFIX = "refs/chant/lease/";
39
+
40
+ /**
41
+ * Side namespace `readLease` fetches remote lease state into (#1959 finding
42
+ * 3), rather than into `refs/chant/lease/<op>` itself. That ref is the CAS
43
+ * write path's alone (`acquireLease`/`releaseLease`, both via
44
+ * `updateRefCAS`/`deleteRefCAS`); a read path force-fetching directly into
45
+ * it would risk clobbering a just-acquired, not-yet-pushed local lease with
46
+ * the still-stale remote value — the exact race a concurrent `chant operator
47
+ * status` in the same clone could hit during the acquire→push window. See
48
+ * `readLease`'s doc for how the two are reconciled without that risk.
49
+ */
50
+ export const LEASE_REMOTE_TRACKING_PREFIX = "refs/chant/lease-remote/";
51
+
52
+ /**
53
+ * Default lease TTL — long enough that a normal tick (observe, classify,
54
+ * a budget-bounded number of dispatches) finishes well inside it; short
55
+ * enough that a crashed operator's environment resumes converging soon
56
+ * after, without a human intervening. The operator loop renews well before
57
+ * this elapses (every round it still owns the lease for), so under normal
58
+ * operation the TTL is never actually reached.
59
+ */
60
+ export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
61
+
62
+ export function leaseRef(opName: string): string {
63
+ return `${LEASE_REF_PREFIX}${opName}`;
64
+ }
65
+
66
+ function leaseRemoteTrackingRef(opName: string): string {
67
+ return `${LEASE_REMOTE_TRACKING_PREFIX}${opName}`;
68
+ }
69
+
70
+ /**
71
+ * Sort key for "which of two lease records is more current" — a plain
72
+ * string comparison works because `acquiredAt`/`expiresAt` are always
73
+ * `Date.prototype.toISOString()` output (fixed-width, UTC), which sorts
74
+ * lexicographically in time order. Compares `acquiredAt` first (a genuine
75
+ * handoff to a new holder always mints a strictly later one; see
76
+ * `acquireLease`), falling back to `expiresAt` to break a tie between two
77
+ * renewals of the *same* holder/token, which share `acquiredAt` by design.
78
+ * `undefined` sorts before every real record.
79
+ */
80
+ function leaseFreshnessKey(record?: LeaseRecord): string {
81
+ return record ? `${record.acquiredAt} ${record.expiresAt}` : "";
82
+ }
83
+
84
+ /** One lease's live state — the entire durable record; there is no history, only the current holder (see this module's doc on why no separate ledger). */
85
+ export interface LeaseRecord {
86
+ op: string;
87
+ /** `<hostname>:<pid>:<random>` — a diagnostic identity, not itself the fencing mechanism (`token` is). */
88
+ holder: string;
89
+ /** Fencing token — new only when the lease actually changes hands; see module doc. */
90
+ token: string;
91
+ acquiredAt: string;
92
+ expiresAt: string;
93
+ }
94
+
95
+ /** A stable-enough identity for "who holds this lease", for logs and `chant operator status` — hostname:pid, plus a short random suffix so two processes started in the same pid-reuse window never read as the same holder. */
96
+ export function currentHolderId(): string {
97
+ return `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
98
+ }
99
+
100
+ function isExpired(record: LeaseRecord, now: Date): boolean {
101
+ return new Date(record.expiresAt).getTime() <= now.getTime();
102
+ }
103
+
104
+ function parseLease(raw: string): LeaseRecord | undefined {
105
+ try {
106
+ const v = JSON.parse(raw) as Partial<LeaseRecord>;
107
+ if (
108
+ typeof v.op === "string" &&
109
+ typeof v.holder === "string" &&
110
+ typeof v.token === "string" &&
111
+ typeof v.acquiredAt === "string" &&
112
+ typeof v.expiresAt === "string"
113
+ ) {
114
+ return v as LeaseRecord;
115
+ }
116
+ return undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ export interface ReadLeaseResult {
123
+ /** The ref's current SHA (the CAS anchor for the next write), or `null` if no lease has ever been written. */
124
+ sha: string | null;
125
+ record?: LeaseRecord;
126
+ }
127
+
128
+ /**
129
+ * Read the live lease for `opName`, fetching the remote ref first (best-
130
+ * effort) so a lease held by another machine is visible before deciding
131
+ * whether to acquire.
132
+ *
133
+ * The fetch lands in a side tracking ref (`refs/chant/lease-remote/<op>`),
134
+ * never directly into `refs/chant/lease/<op>` itself (#1959 finding 3) — the
135
+ * canonical local ref is written *only* by the CAS path
136
+ * (`acquireLease`/`releaseLease`), so a read (this function is called
137
+ * before every `acquireLease`, and directly by `chant operator status`) can
138
+ * never force it back to a stale remote value out from under a concurrent
139
+ * local acquirer. The returned `record` is whichever of the local/remote
140
+ * views is more current by `leaseFreshnessKey` (ties keep local): this
141
+ * still gives full cross-machine visibility — a genuinely newer remote
142
+ * holder wins — while a just-acquired, not-yet-pushed local lease (freshest
143
+ * by construction) always survives a same-clone concurrent read. `sha`
144
+ * — the CAS anchor a subsequent `acquireLease`/`releaseLease` writes
145
+ * against — is always the local ref's own actual value; only the local
146
+ * canonical ref is ever a valid basis for a `updateRefCAS`/`deleteRefCAS`
147
+ * call against it, regardless of what the comparison decided about `record`.
148
+ */
149
+ export async function readLease(opName: string, opts?: { cwd?: string }): Promise<ReadLeaseResult> {
150
+ const ref = leaseRef(opName);
151
+ const trackingRef = leaseRemoteTrackingRef(opName);
152
+ await fetchRefInto(ref, trackingRef, opts).catch(() => undefined);
153
+
154
+ const sha = await readRefSha(ref, opts);
155
+ const localRecord = sha ? parseLease((await readBlobBySha(sha, opts)) ?? "") : undefined;
156
+
157
+ const remoteSha = await readRefSha(trackingRef, opts);
158
+ const remoteRecord = remoteSha ? parseLease((await readBlobBySha(remoteSha, opts)) ?? "") : undefined;
159
+
160
+ const record = leaseFreshnessKey(remoteRecord) > leaseFreshnessKey(localRecord) ? remoteRecord : localRecord;
161
+ return { sha, record };
162
+ }
163
+
164
+ export interface AcquireLeaseResult {
165
+ acquired: boolean;
166
+ lease?: LeaseRecord;
167
+ /** Present when not acquired: the lease record currently held by someone else. */
168
+ heldBy?: LeaseRecord;
169
+ }
170
+
171
+ /**
172
+ * Acquire or renew the lease for `opName` as `holder`. Succeeds when the ref
173
+ * doesn't exist yet, is expired, or is already held by `holder` (a renewal:
174
+ * same token, pushed-out expiry). Fails — returns `acquired: false`, without
175
+ * throwing — when it's live-held by someone else, or when a concurrent CAS
176
+ * write is lost to a race that happened between this call's read and its
177
+ * write ({@link RefCASConflictError}); both read identically to a caller
178
+ * deciding whether to tick this round ("someone else has it right now,
179
+ * skip").
180
+ *
181
+ * Deliberately does NOT swallow a `StaleLockError` (./git.ts) into that same
182
+ * "someone else has it" outcome (#1959 finding 2): a leftover `.lock` file
183
+ * from a killed process is not contention, it's wreckage, and treating it as
184
+ * "held by someone else" would make `chant operator` back off forever
185
+ * against a lease nobody can ever actually acquire again without manual
186
+ * intervention. It propagates instead, so the caller (`../op/operator.ts`'s
187
+ * `runOperatorRound`) can surface it as its own distinct, diagnosable event
188
+ * rather than a silent, permanent skip.
189
+ */
190
+ export async function acquireLease(
191
+ opName: string,
192
+ holder: string,
193
+ opts?: { cwd?: string; ttlMs?: number; now?: () => Date },
194
+ ): Promise<AcquireLeaseResult> {
195
+ const ttlMs = opts?.ttlMs ?? DEFAULT_LEASE_TTL_MS;
196
+ const now = opts?.now?.() ?? new Date();
197
+
198
+ const { sha, record: current } = await readLease(opName, opts);
199
+ const expired = !current || isExpired(current, now);
200
+ const ownedByUs = current?.holder === holder;
201
+
202
+ if (current && !expired && !ownedByUs) {
203
+ return { acquired: false, heldBy: current };
204
+ }
205
+
206
+ const renewing = !!current && ownedByUs && !expired;
207
+ const record: LeaseRecord = {
208
+ op: opName,
209
+ holder,
210
+ token: renewing ? current.token : randomUUID(),
211
+ acquiredAt: renewing ? current.acquiredAt : now.toISOString(),
212
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
213
+ };
214
+
215
+ const blobSha = await writeBlob(JSON.stringify(record), opts);
216
+ try {
217
+ await updateRefCAS(leaseRef(opName), blobSha, sha, opts);
218
+ } catch (err) {
219
+ if (err instanceof RefCASConflictError) {
220
+ const retry = await readLease(opName, opts);
221
+ return { acquired: false, heldBy: retry.record };
222
+ }
223
+ // A StaleLockError (or any other non-CAS failure) is NOT "someone else
224
+ // has it" — propagate it as its own distinct error rather than folding
225
+ // it into `heldBy`, per this function's doc.
226
+ throw err;
227
+ }
228
+ await pushRef(leaseRef(opName), opts).catch(() => undefined);
229
+ return { acquired: true, lease: record };
230
+ }
231
+
232
+ /**
233
+ * Release the lease, but only when `holder`/`token` still match the live
234
+ * value — releasing a lease this caller no longer actually holds would
235
+ * silently drop someone else's. Best-effort courtesy: a lease nobody
236
+ * releases is reclaimed anyway once its TTL passes, so a failed release
237
+ * (returns `false`, never throws) is not itself a correctness problem.
238
+ */
239
+ export async function releaseLease(
240
+ opName: string,
241
+ holder: string,
242
+ token: string,
243
+ opts?: { cwd?: string },
244
+ ): Promise<boolean> {
245
+ const { sha, record } = await readLease(opName, opts);
246
+ if (!sha || !record || record.holder !== holder || record.token !== token) return false;
247
+ try {
248
+ await deleteRefCAS(leaseRef(opName), sha, opts);
249
+ } catch {
250
+ return false;
251
+ }
252
+ await pushRef(leaseRef(opName), opts).catch(() => undefined);
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * Does `holder`/`token` still match the live lease? The fencing check a
258
+ * tick uses right before trusting its own work as authoritative (see
259
+ * `../op/operator.ts`). Fetches first, so a lease stolen by another machine
260
+ * is detected, not just a stale local read.
261
+ */
262
+ export async function stillHoldsLease(
263
+ opName: string,
264
+ holder: string,
265
+ token: string,
266
+ opts?: { cwd?: string },
267
+ ): Promise<boolean> {
268
+ const { record } = await readLease(opName, opts);
269
+ return record?.holder === holder && record?.token === token;
270
+ }
@@ -97,3 +97,49 @@ describe("acceptDeviations", () => {
97
97
  expect(countAccepted(null)).toBe(0);
98
98
  });
99
99
  });
100
+
101
+ describe("acceptDeviations refuses effect receipts (#1833)", () => {
102
+ const now = "2026-08-24T00:00:00.000Z";
103
+
104
+ test("refuses a deviation whose type is the core receipt entityType", () => {
105
+ expect(() =>
106
+ acceptDeviations(emptyBaseline("prod"), "chant", [
107
+ { entity: "seededReceipt", type: "Chant::EffectReceipt", path: "value", value: "gone" },
108
+ ], { now }),
109
+ ).toThrow(/effect receipt "seededReceipt"/);
110
+ });
111
+
112
+ test("refuses a deviation on an entity the caller recognized as a receipt (materialized row)", () => {
113
+ expect(() =>
114
+ acceptDeviations(emptyBaseline("prod"), "aws", [
115
+ { entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
116
+ ], { now, receipts: new Set(["migratedReceipt"]) }),
117
+ ).toThrow(/effect receipt "migratedReceipt"/);
118
+ });
119
+
120
+ test("the refusal names the receipt and the effect step as sole writer, and records nothing", () => {
121
+ const before = emptyBaseline("prod");
122
+ let error: Error | undefined;
123
+ try {
124
+ acceptDeviations(before, "aws", [
125
+ { entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
126
+ { entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
127
+ ], { now, receipts: new Set(["migratedReceipt"]) });
128
+ } catch (e) {
129
+ error = e as Error;
130
+ }
131
+ expect(error).toBeDefined();
132
+ expect(error!.message).toContain('effect receipt "migratedReceipt"');
133
+ expect(error!.message).toContain("only writer");
134
+ expect(error!.message).toContain("defuse the effect");
135
+ // The whole acceptance aborts — the non-receipt row was not recorded either.
136
+ expect(before.lexicons).toEqual({});
137
+ });
138
+
139
+ test("passes: non-receipt deviations accept as before, receipts set present", () => {
140
+ const b = acceptDeviations(emptyBaseline("prod"), "aws", [
141
+ { entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
142
+ ], { now, receipts: new Set(["migratedReceipt"]) });
143
+ expect(baselineForLexicon(b, "aws").Role.accepted).toHaveLength(1);
144
+ });
145
+ });