@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,199 @@
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 } from "node:fs";
5
+ import { join } from "node:path";
6
+ import {
7
+ appendConvergeRecord,
8
+ readConvergeLedger,
9
+ consecutiveRuleFires,
10
+ type ConvergeTickRecordInput,
11
+ type ConvergeTickRecord,
12
+ } from "./converge-ledger";
13
+
14
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
15
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
16
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
17
+ }
18
+
19
+ async function initRepo(dir: string): Promise<void> {
20
+ git(["init", "-q", "-b", "main"], dir);
21
+ git(["config", "user.email", "test@chant.dev"], dir);
22
+ git(["config", "user.name", "Test"], dir);
23
+ writeFileSync(join(dir, "README.md"), "fixture\n");
24
+ git(["add", "README.md"], dir);
25
+ git(["commit", "-q", "-m", "init"], dir);
26
+ }
27
+
28
+ function makeInput(overrides?: Partial<ConvergeTickRecordInput>): ConvergeTickRecordInput {
29
+ return {
30
+ op: "fountain-converge",
31
+ env: "staging",
32
+ timestamp: "2026-01-01T00:00:00.000Z",
33
+ firedRuleIds: [],
34
+ outcomes: [],
35
+ summary: { drifted: 0, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0 },
36
+ log: "converge(staging): drifted=0 remediated=0 reported=0 skipped-budget=0 skipped-flap=0 unobserved=0 adopted=0",
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ describe("converge-ledger", () => {
42
+ describe("appendConvergeRecord / readConvergeLedger", () => {
43
+ test("round-trips a single record", async () => {
44
+ await withTestDir(async (dir) => {
45
+ await initRepo(dir);
46
+ const { record } = await appendConvergeRecord(makeInput(), { cwd: dir });
47
+ expect(record.version).toBe(1);
48
+
49
+ const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
50
+ expect(malformed).toBe(0);
51
+ expect(records).toEqual([record]);
52
+ });
53
+ });
54
+
55
+ test("appends without clobbering prior records", async () => {
56
+ await withTestDir(async (dir) => {
57
+ await initRepo(dir);
58
+ await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:00:00.000Z" }), { cwd: dir });
59
+ await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:10:00.000Z" }), { cwd: dir });
60
+ await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:20:00.000Z" }), { cwd: dir });
61
+
62
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
63
+ expect(records).toHaveLength(3);
64
+ expect(records.map((r) => r.timestamp)).toEqual([
65
+ "2026-01-01T00:00:00.000Z",
66
+ "2026-01-01T00:10:00.000Z",
67
+ "2026-01-01T00:20:00.000Z",
68
+ ]);
69
+ });
70
+ });
71
+
72
+ test("empty ledger for an environment with no records — never throws", async () => {
73
+ await withTestDir(async (dir) => {
74
+ await initRepo(dir);
75
+ const { records, malformed } = await readConvergeLedger("nowhere", { cwd: dir });
76
+ expect(records).toEqual([]);
77
+ expect(malformed).toBe(0);
78
+ });
79
+ });
80
+
81
+ test("skips malformed lines but reports the count", async () => {
82
+ await withTestDir(async (dir) => {
83
+ await initRepo(dir);
84
+ await appendConvergeRecord(makeInput(), { cwd: dir });
85
+ // Directly corrupt the ledger by writing a second, non-JSON line via a
86
+ // second append whose "record" is actually malformed content — simulate
87
+ // by reading, then re-appending raw garbage through the same git path
88
+ // a hand-edited ledger would produce. We reuse appendConvergeRecord's
89
+ // underlying git plumbing indirectly by writing a second valid record,
90
+ // then asserting the reader tolerates an out-of-schema line mixed in
91
+ // at read time isn't directly expressible via the public API, so this
92
+ // test instead exercises the schema-validation branch: a record
93
+ // missing `firedRuleIds` is treated as malformed.
94
+ const { readBlobFromPath, writeBlobToPath } = await import("./git");
95
+ const existing = await readBlobFromPath("staging", "converge.jsonl", { cwd: dir });
96
+ const corrupted = `${existing}\n${JSON.stringify({ version: 1, op: "x", env: "staging" })}`;
97
+ await writeBlobToPath("staging", "converge.jsonl", corrupted, "corrupt", { cwd: dir });
98
+
99
+ const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
100
+ expect(records).toHaveLength(1);
101
+ expect(malformed).toBe(1);
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("consecutiveRuleFires", () => {
107
+ function rec(firedRuleIds: string[]): ConvergeTickRecord {
108
+ return { ...makeInput({ firedRuleIds }), version: 1 };
109
+ }
110
+
111
+ test("0 for an empty ledger", () => {
112
+ expect(consecutiveRuleFires([], "r1")).toBe(0);
113
+ });
114
+
115
+ test("0 when the newest tick didn't fire it", () => {
116
+ const records = [rec(["r1"]), rec(["r1"]), rec([])];
117
+ expect(consecutiveRuleFires(records, "r1")).toBe(0);
118
+ });
119
+
120
+ test("counts consecutive fires from the newest tick backward", () => {
121
+ const records = [rec([]), rec(["r1"]), rec(["r1"]), rec(["r1"])];
122
+ expect(consecutiveRuleFires(records, "r1")).toBe(3);
123
+ });
124
+
125
+ test("stops counting at the first non-firing tick, scanning backward", () => {
126
+ const records = [rec(["r1"]), rec([]), rec(["r1"]), rec(["r1"])];
127
+ expect(consecutiveRuleFires(records, "r1")).toBe(2);
128
+ });
129
+
130
+ test("only counts the named rule, ignoring others", () => {
131
+ const records = [rec(["r2"]), rec(["r1", "r2"])];
132
+ expect(consecutiveRuleFires(records, "r1")).toBe(1);
133
+ expect(consecutiveRuleFires(records, "r2")).toBe(2);
134
+ });
135
+ });
136
+
137
+ // ── Gate-as-fact (#1485) ───────────────────────────────────────────────────
138
+
139
+ describe("gated outcome", () => {
140
+ test("round-trips a 'gated' outcome with its gateName, and a gated count in the summary", async () => {
141
+ await withTestDir(async (dir) => {
142
+ await initRepo(dir);
143
+ const { record } = await appendConvergeRecord(
144
+ makeInput({
145
+ firedRuleIds: ["drift-apply"],
146
+ outcomes: [
147
+ {
148
+ ruleId: "drift-apply",
149
+ action: "gated",
150
+ op: "fountain-apply",
151
+ gateName: "rollout-gate",
152
+ reason: 'dispatch of "fountain-apply" hit gate "rollout-gate"',
153
+ },
154
+ ],
155
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
156
+ }),
157
+ { cwd: dir },
158
+ );
159
+ expect(record.outcomes[0]).toEqual({
160
+ ruleId: "drift-apply",
161
+ action: "gated",
162
+ op: "fountain-apply",
163
+ gateName: "rollout-gate",
164
+ reason: 'dispatch of "fountain-apply" hit gate "rollout-gate"',
165
+ });
166
+ expect(record.summary.gated).toBe(1);
167
+
168
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
169
+ expect(records).toEqual([record]);
170
+ });
171
+ });
172
+ });
173
+
174
+ // ── Concurrent local writers (#1485) ────────────────────────────────────────
175
+
176
+ describe("appendConvergeRecord retries on RefCASConflictError", () => {
177
+ test("a concurrent writer to a different env's file on the same orphan branch doesn't lose either write", async () => {
178
+ await withTestDir(async (dir) => {
179
+ await initRepo(dir);
180
+ // Two "operators" appending to two different environments' ledgers on
181
+ // the same orphan branch, interleaved so their internal git-plumbing
182
+ // reads race — this is exactly the scenario `writeBlobToPath`'s CAS
183
+ // guard (chant #1485) now protects against, and `appendConvergeRecord`
184
+ // retries through rather than surfacing to the caller.
185
+ const [a, b] = await Promise.all([
186
+ appendConvergeRecord(makeInput({ env: "staging", op: "staging-converge" }), { cwd: dir }),
187
+ appendConvergeRecord(makeInput({ env: "prod", op: "prod-converge" }), { cwd: dir }),
188
+ ]);
189
+ expect(a.record.env).toBe("staging");
190
+ expect(b.record.env).toBe("prod");
191
+
192
+ const staging = await readConvergeLedger("staging", { cwd: dir });
193
+ const prod = await readConvergeLedger("prod", { cwd: dir });
194
+ expect(staging.records).toHaveLength(1);
195
+ expect(prod.records).toHaveLength(1);
196
+ });
197
+ });
198
+ });
199
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Converge ledger (#1484) — the append-only record of what a `ConvergeOp`
3
+ * tick did, one line per tick, at `<env>/converge.jsonl` on the
4
+ * `chant/lifecycle` orphan branch. Same storage shape as the release ledger
5
+ * (./release-ledger.ts): immutable, append-only JSONL, reusing
6
+ * `writeBlobToPath`/`readBlobFromPath` (./git.ts) directly rather than a new
7
+ * git-plumbing path — the exact reuse `appendReleaseRecordLine` already
8
+ * demonstrates for a second filename under the same env directory.
9
+ *
10
+ * This is also where flap-damping state lives (issue: "consecutive-fire
11
+ * counts go in the ledger ... not process memory, so damping survives worker
12
+ * restarts"): each record's `firedRuleIds` names every rule whose predicate
13
+ * matched this tick, independent of what action was actually taken (run,
14
+ * report, or skipped for budget/flap reasons) — {@link consecutiveRuleFires}
15
+ * walks the ledger backward from the newest record and counts how many in a
16
+ * row include a given rule id, stopping at the first tick where it didn't
17
+ * fire (the symptom cleared).
18
+ */
19
+ import { sortedJsonReplacer } from "../utils";
20
+ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
21
+
22
+ const FILENAME = "converge.jsonl";
23
+
24
+ /**
25
+ * Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
26
+ * `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
27
+ * (another writer, e.g. a second operator ticking a different env on the
28
+ * same orphan branch, updated the branch tip between our read and our
29
+ * write) throws `RefCASConflictError` instead of silently clobbering. This
30
+ * many attempts, re-reading the ledger fresh each time, absorbs that race
31
+ * rather than surfacing it as a tick failure — the whole point of the CAS
32
+ * guard is correctness, not a new way for a tick to fail.
33
+ */
34
+ const APPEND_RETRY_ATTEMPTS = 5;
35
+
36
+ /** One rule's outcome within a tick. */
37
+ export interface ConvergeRuleOutcome {
38
+ ruleId: string;
39
+ /**
40
+ * What actually happened for this fired rule this tick. `"gated"` (#1485)
41
+ * is gate-as-fact: the dispatched op's own run hit a gate it can't clear
42
+ * on the local executor, and the tick records that as a terminal, durable
43
+ * fact — `gateName` names the gate — rather than treating it as a
44
+ * dispatch failure. Resolution is out-of-band (`chant approve <op>
45
+ * <gate>`, ./gate-ledger.ts, or a merged PR); the tick itself never
46
+ * blocks waiting for it.
47
+ */
48
+ action: "ran" | "reported" | "skipped-budget" | "skipped-flap" | "gated";
49
+ /** The dispatched Op name, for `action: "ran"` or `"gated"`. */
50
+ op?: string;
51
+ /** The gate's signal name, for `action: "gated"`. */
52
+ gateName?: string;
53
+ /** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
54
+ reason?: string;
55
+ }
56
+
57
+ /** One immutable converge-tick record. */
58
+ export interface ConvergeTickRecord {
59
+ /** Schema version, so an incompatible future shape is detected before being misread. */
60
+ version: 1;
61
+ /** The ConvergeOp's name (`OpConfig.name`). */
62
+ op: string;
63
+ env: string;
64
+ /** ISO-8601 timestamp, caller-supplied (same convention as `ReleaseRecord.timestamp` — library code never calls `Date.now()` internally). */
65
+ timestamp: string;
66
+ /** Every rule id whose predicate matched this tick, regardless of what action followed — the flap-damping input. */
67
+ firedRuleIds: string[];
68
+ /** Per-rule outcome, for every fired rule. */
69
+ outcomes: ConvergeRuleOutcome[];
70
+ /** Aggregate counts backing the tick's one log line. */
71
+ summary: {
72
+ drifted: number;
73
+ remediated: number;
74
+ reported: number;
75
+ skippedBudget: number;
76
+ skippedFlap: number;
77
+ unobserved: number;
78
+ adopted: number;
79
+ /** Rules whose dispatch hit a gate this tick (#1485) — a terminal, non-blocking fact; see `ConvergeRuleOutcome.action`'s doc. @default 0, so an older record without this field still reads as zero, not undefined. */
80
+ gated?: number;
81
+ };
82
+ /** The one human-readable log line this tick produced (issue: "one log line and one ledger record per tick"). */
83
+ log: string;
84
+ }
85
+
86
+ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
87
+
88
+ /**
89
+ * Append one immutable tick record. Does not push to the remote — call
90
+ * `pushLifecycle` (./git.ts) afterward, same two-step shape every other
91
+ * ledger write here uses.
92
+ *
93
+ * Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
94
+ * — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
95
+ * a *different* env's file on the same orphan branch (two operators ticking
96
+ * two environments at once is the ordinary case, not an edge case) can lose
97
+ * the race and needs to re-read the branch tip and retry, not fail the
98
+ * tick. Each retry re-reads `existing` fresh, so it always appends onto
99
+ * whatever the other writer just committed rather than reintroducing a stale
100
+ * read. Exhausting the budget re-throws the conflict — a real, sustained
101
+ * pile-up of writers is a signal worth surfacing, not silently swallowing.
102
+ *
103
+ * The baseline read must be `readPathSha` + `readBlobBySha` rather than
104
+ * `readBlobFromPath`, so the exact sha `existing` came from can be passed as
105
+ * `expectPriorPathSha`. See `writeBlobToPath` (./git.ts) for the race that
106
+ * closes.
107
+ */
108
+ export async function appendConvergeRecord(
109
+ input: ConvergeTickRecordInput,
110
+ opts?: { cwd?: string },
111
+ ): Promise<{ commit: string; record: ConvergeTickRecord }> {
112
+ const record: ConvergeTickRecord = { version: 1, ...input };
113
+ const json = JSON.stringify(record, sortedJsonReplacer);
114
+
115
+ let lastErr: unknown;
116
+ for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
117
+ try {
118
+ const priorSha = await readPathSha(record.env, FILENAME, opts);
119
+ const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
120
+ const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
121
+ const commit = await writeBlobToPath(record.env, FILENAME, content, "Converge tick record", {
122
+ ...opts,
123
+ expectPriorPathSha: priorSha,
124
+ });
125
+ return { commit, record };
126
+ } catch (err) {
127
+ if (!(err instanceof RefCASConflictError)) throw err;
128
+ lastErr = err;
129
+ }
130
+ }
131
+ throw lastErr;
132
+ }
133
+
134
+ /** Read every tick record for `environment`, oldest first. Malformed lines are skipped, not thrown on — a corrupted or hand-edited ledger degrades gracefully, the same stance `readReleaseLedger` takes. */
135
+ export async function readConvergeLedger(
136
+ environment: string,
137
+ opts?: { cwd?: string },
138
+ ): Promise<{ records: ConvergeTickRecord[]; malformed: number }> {
139
+ const content = await readBlobFromPath(environment, FILENAME, opts);
140
+ if (!content) return { records: [], malformed: 0 };
141
+
142
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
143
+ const records: ConvergeTickRecord[] = [];
144
+ let malformed = 0;
145
+ for (const line of lines) {
146
+ try {
147
+ const parsed = JSON.parse(line) as Partial<ConvergeTickRecord>;
148
+ if (
149
+ parsed.version !== 1 ||
150
+ typeof parsed.op !== "string" ||
151
+ typeof parsed.env !== "string" ||
152
+ typeof parsed.timestamp !== "string" ||
153
+ !Array.isArray(parsed.firedRuleIds)
154
+ ) {
155
+ malformed++;
156
+ continue;
157
+ }
158
+ records.push(parsed as ConvergeTickRecord);
159
+ } catch {
160
+ malformed++;
161
+ }
162
+ }
163
+ return { records, malformed };
164
+ }
165
+
166
+ /**
167
+ * How many consecutive most-recent ticks (newest first) fired `ruleId`,
168
+ * stopping at the first tick where it did not — the count a rule's
169
+ * `flapThreshold` is compared against. `0` when the newest tick didn't fire
170
+ * it (including an empty ledger).
171
+ */
172
+ export function consecutiveRuleFires(records: ConvergeTickRecord[], ruleId: string): number {
173
+ let count = 0;
174
+ for (let i = records.length - 1; i >= 0; i--) {
175
+ if (!records[i].firedRuleIds.includes(ruleId)) break;
176
+ count++;
177
+ }
178
+ return count;
179
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { countPropertyDrift, diffDeep } from "./deep-diff";
2
+ import { countPropertyDrift, diffDeep, type DeclaredDeepEntity } from "./deep-diff";
3
3
  import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation";
4
4
  import type { BaselineLexicon } from "./observation-baseline";
5
5
 
@@ -202,3 +202,81 @@ describe("diffDeep — owning field manager (#1189)", () => {
202
202
  expect(change).not.toHaveProperty("owner");
203
203
  });
204
204
  });
205
+
206
+ // #1443 — the declared-side counterpart of `owner`. Both sides of one
207
+ // comparison get precise attribution, which is what makes the report actionable.
208
+ describe("diffDeep — declared-side origin (#1443)", () => {
209
+ // Annotated, not `as const`: the annotation is what keeps `kind` narrowed to
210
+ // its literal, and it types the fixture as the production shape rather than
211
+ // whatever the literal happens to infer to.
212
+ const declared: Record<string, DeclaredDeepEntity> = {
213
+ web: {
214
+ type: "K8s::Apps::Deployment",
215
+ properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
216
+ pathOrigins: {
217
+ "": { kind: "composite", composite: "WebService", instance: "web" },
218
+ "spec.replicas": { kind: "build-param", params: ["tier"] },
219
+ },
220
+ },
221
+ };
222
+
223
+ test("reports the origin alongside the live owner", () => {
224
+ const result = diffDeep({
225
+ declared,
226
+ live: live({
227
+ web: {
228
+ type: "K8s::Apps::Deployment",
229
+ properties: { spec: { replicas: 5, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
230
+ fieldOwners: { "spec.replicas": "hpa-controller" },
231
+ },
232
+ }),
233
+ });
234
+ expect(result.drifted[0].changes[0]).toMatchObject({
235
+ path: "spec.replicas",
236
+ owner: "hpa-controller",
237
+ origin: { kind: "build-param", params: ["tier"] },
238
+ });
239
+ });
240
+
241
+ test("a path with no origin of its own inherits the nearest recorded ancestor", () => {
242
+ const result = diffDeep({
243
+ declared,
244
+ live: live({
245
+ web: {
246
+ type: "K8s::Apps::Deployment",
247
+ properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:2" }] } } } },
248
+ },
249
+ }),
250
+ });
251
+ const change = result.drifted[0].changes.find((c) => c.path.includes("image"))!;
252
+ expect(change.origin).toEqual({ kind: "composite", composite: "WebService", instance: "web" });
253
+ });
254
+
255
+ test("is absent on an undeclared path — nothing in source produced it", () => {
256
+ const result = diffDeep({
257
+ declared,
258
+ live: live({
259
+ web: {
260
+ type: "K8s::Apps::Deployment",
261
+ properties: {
262
+ spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } },
263
+ status: { observed: 1 },
264
+ },
265
+ },
266
+ }),
267
+ });
268
+ const change = result.drifted[0].changes.find((c) => c.path === "status.observed")!;
269
+ expect(change.kind).toBe("undeclared");
270
+ expect(change).not.toHaveProperty("origin");
271
+ });
272
+
273
+ test("is absent when the build recorded no path origins", () => {
274
+ // The run path, and a sandboxed child: absent means "could not record",
275
+ // not "nothing governs this field", so the key must not appear at all.
276
+ const result = diffDeep({
277
+ declared: { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } },
278
+ live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
279
+ });
280
+ expect(result.drifted[0].changes[0]).not.toHaveProperty("origin");
281
+ });
282
+ });
@@ -29,6 +29,7 @@ import {
29
29
  type DeepNormalizationHooks,
30
30
  type NormalizedDeepObservation,
31
31
  } from "../deep-observation";
32
+ import { originOfPath, type PathOrigin } from "../provenance";
32
33
  import type { UnobservedResource } from "./live-diff";
33
34
  import { acceptedDeviation, type BaselineLexicon } from "./observation-baseline";
34
35
 
@@ -70,6 +71,18 @@ export interface PropertyDrift {
70
71
  * with no per-field ownership, which is every substrate but k8s.
71
72
  */
72
73
  owner?: string;
74
+ /**
75
+ * What produced this path on the DECLARED side (#1443) — the counterpart of
76
+ * {@link owner}, and the reason the two are reported together: "owned live by
77
+ * `hpa-controller`, governed in source by the `tier` parameter" says where
78
+ * each half of a disagreement has to be fixed, which neither half says alone.
79
+ *
80
+ * Resolved by longest prefix from the entity's recorded path origins, so a
81
+ * field inside a keyed list element inherits the origin recorded for the
82
+ * list. Absent when the build recorded none — the run path, and a sandboxed
83
+ * child, have no expression to attribute (see `EntityProvenance.paths`).
84
+ */
85
+ origin?: PathOrigin;
73
86
  }
74
87
 
75
88
  /** Property-level drift for one declared entity. */
@@ -100,6 +113,11 @@ export interface DeepDiffResult {
100
113
  export interface DeclaredDeepEntity {
101
114
  type: string;
102
115
  properties: Record<string, unknown>;
116
+ /**
117
+ * The entity's recorded path origins (#1443), as `EntityProvenance.paths`.
118
+ * Omit for a build that recorded none.
119
+ */
120
+ pathOrigins?: Record<string, PathOrigin>;
103
121
  }
104
122
 
105
123
  export interface DiffDeepInput {
@@ -188,12 +206,17 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
188
206
  // meaningful for a path that exists live — an `absent` drift has no live
189
207
  // field for anyone to own.
190
208
  const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
209
+ // The declared-side counterpart (#1443). Only meaningful for a path
210
+ // source actually declares — an `undeclared` drift has no authored
211
+ // expression for anything to have produced.
212
+ const origin = hasDeclared ? originOfPath(declaredEntity.pathOrigins, path) : undefined;
191
213
  const drift: PropertyDrift = {
192
214
  path,
193
215
  kind,
194
216
  ...(hasDeclared ? { declared: declaredValue } : {}),
195
217
  ...(hasLive ? { live: liveValue } : {}),
196
218
  ...(owner ? { owner } : {}),
219
+ ...(origin ? { origin } : {}),
197
220
  };
198
221
 
199
222
  const acceptedEntry = acceptedDeviation(baseline, name, path);
@@ -27,11 +27,21 @@ import {
27
27
  type NormalizedDeepObservation,
28
28
  } from "../deep-observation";
29
29
  import { unobservedAll, type UnobservedEntity } from "../observation";
30
+ import type { PathOrigin } from "../provenance";
30
31
  import { diffDeep, type DeclaredDeepEntity, type DeepDiffResult } from "./deep-diff";
31
32
  import type { BaselineLexicon } from "./observation-baseline";
32
33
 
33
- /** Declared entities for one lexicon, in the shape the observe paths pass around. */
34
- export type DeclaredEntities = Map<string, { entityType: string; props: Record<string, unknown> }>;
34
+ /**
35
+ * Declared entities for one lexicon, in the shape the observe paths pass around.
36
+ *
37
+ * `pathOrigins` (#1443) rides alongside `props` rather than being read off the
38
+ * entity, because callers rebuild this map as plain objects and the symbol-keyed
39
+ * provenance channel does not survive that copy.
40
+ */
41
+ export type DeclaredEntities = Map<
42
+ string,
43
+ { entityType: string; props: Record<string, unknown>; pathOrigins?: Record<string, PathOrigin> }
44
+ >;
35
45
 
36
46
  export interface DeepObserveOptions {
37
47
  environment: string;
@@ -138,6 +148,7 @@ export function diffDeepObservation(
138
148
  hooks,
139
149
  counterpartPaths: livePaths,
140
150
  }),
151
+ ...(entity.pathOrigins ? { pathOrigins: entity.pathOrigins } : {}),
141
152
  };
142
153
  if (liveEntity) {
143
154
  normalizedLive[name] = {
@@ -0,0 +1,103 @@
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 } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { appendGateResolution, readGateResolutions, latestResolutionSince } from "./gate-ledger";
7
+ import { readBlobFromPath } from "./git";
8
+
9
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
10
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
11
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
12
+ }
13
+
14
+ async function initRepo(dir: string): Promise<void> {
15
+ git(["init", "-q", "-b", "main"], dir);
16
+ git(["config", "user.email", "test@chant.dev"], dir);
17
+ git(["config", "user.name", "Test"], dir);
18
+ writeFileSync(join(dir, "README.md"), "fixture\n");
19
+ git(["add", "README.md"], dir);
20
+ git(["commit", "-q", "-m", "init"], dir);
21
+ }
22
+
23
+ describe("lifecycle/gate-ledger", () => {
24
+ test("appendGateResolution + readGateResolutions round-trip", async () => {
25
+ await withTestDir(async (dir) => {
26
+ await initRepo(dir);
27
+ const { record } = await appendGateResolution(
28
+ { op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
29
+ { cwd: dir },
30
+ );
31
+ expect(record.version).toBe(1);
32
+
33
+ const { records, malformed } = await readGateResolutions("fountain-apply", { cwd: dir });
34
+ expect(malformed).toBe(0);
35
+ expect(records).toEqual([record]);
36
+ });
37
+ });
38
+
39
+ test("stores under a global _gates namespace, keyed by op — not per-environment", async () => {
40
+ await withTestDir(async (dir) => {
41
+ await initRepo(dir);
42
+ await appendGateResolution(
43
+ { op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
44
+ { cwd: dir },
45
+ );
46
+ const raw = await readBlobFromPath("_gates", "fountain-apply.jsonl", { cwd: dir });
47
+ expect(raw).toContain("rollout-gate");
48
+ });
49
+ });
50
+
51
+ test("readGateResolutions returns [] for an op with no resolutions yet", async () => {
52
+ await withTestDir(async (dir) => {
53
+ await initRepo(dir);
54
+ expect(await readGateResolutions("no-such-op", { cwd: dir })).toEqual({ records: [], malformed: 0 });
55
+ });
56
+ });
57
+
58
+ test("appends without clobbering — multiple gates/resolutions on the same op accumulate", async () => {
59
+ await withTestDir(async (dir) => {
60
+ await initRepo(dir);
61
+ await appendGateResolution(
62
+ { op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
63
+ { cwd: dir },
64
+ );
65
+ await appendGateResolution(
66
+ { op: "fountain-apply", gate: "prod-gate", resolvedBy: "sam", timestamp: "2026-01-02T00:00:00.000Z", note: "https://github.com/x/y/pull/1" },
67
+ { cwd: dir },
68
+ );
69
+ const { records } = await readGateResolutions("fountain-apply", { cwd: dir });
70
+ expect(records.map((r) => r.gate)).toEqual(["rollout-gate", "prod-gate"]);
71
+ expect(records[1].note).toBe("https://github.com/x/y/pull/1");
72
+ });
73
+ });
74
+
75
+ describe("latestResolutionSince", () => {
76
+ test("finds a resolution recorded after the gated tick's own timestamp", () => {
77
+ const records = [
78
+ { version: 1 as const, op: "x", gate: "g1", resolvedBy: "a", timestamp: "2026-01-01T00:00:00.000Z" },
79
+ { version: 1 as const, op: "x", gate: "g1", resolvedBy: "b", timestamp: "2026-01-03T00:00:00.000Z" },
80
+ ];
81
+ const found = latestResolutionSince(records, "g1", "2026-01-02T00:00:00.000Z");
82
+ expect(found?.resolvedBy).toBe("b");
83
+ });
84
+
85
+ test("returns undefined when the only resolution predates the gated tick (a stale, superseded approval)", () => {
86
+ const records = [
87
+ { version: 1 as const, op: "x", gate: "g1", resolvedBy: "a", timestamp: "2026-01-01T00:00:00.000Z" },
88
+ ];
89
+ expect(latestResolutionSince(records, "g1", "2026-01-02T00:00:00.000Z")).toBeUndefined();
90
+ });
91
+
92
+ test("ignores resolutions for a different gate", () => {
93
+ const records = [
94
+ { version: 1 as const, op: "x", gate: "other-gate", resolvedBy: "a", timestamp: "2026-01-05T00:00:00.000Z" },
95
+ ];
96
+ expect(latestResolutionSince(records, "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
97
+ });
98
+
99
+ test("undefined when there are no resolutions at all", () => {
100
+ expect(latestResolutionSince([], "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
101
+ });
102
+ });
103
+ });