@intentius/chant 0.124.39 → 0.124.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/component-endpoint.d.ts +20 -0
  11. package/dist/cli/component-endpoint.d.ts.map +1 -0
  12. package/dist/cli/component-endpoint.js +36 -0
  13. package/dist/cli/handlers/approve.d.ts +2 -1
  14. package/dist/cli/handlers/approve.d.ts.map +1 -1
  15. package/dist/cli/handlers/approve.js +20 -6
  16. package/dist/cli/handlers/build.d.ts.map +1 -1
  17. package/dist/cli/handlers/build.js +15 -1
  18. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  19. package/dist/cli/handlers/fan-out.js +26 -4
  20. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  21. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  22. package/dist/cli/handlers/gate-resume.js +15 -2
  23. package/dist/cli/handlers/lock.d.ts +8 -0
  24. package/dist/cli/handlers/lock.d.ts.map +1 -0
  25. package/dist/cli/handlers/lock.js +197 -0
  26. package/dist/cli/handlers/pr-env.d.ts +34 -0
  27. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  28. package/dist/cli/handlers/pr-env.js +436 -0
  29. package/dist/cli/handlers/pr.d.ts +74 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +514 -58
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +16 -2
  34. package/dist/cli/handlers/query.d.ts +6 -0
  35. package/dist/cli/handlers/query.d.ts.map +1 -0
  36. package/dist/cli/handlers/query.js +80 -0
  37. package/dist/cli/handlers/run.d.ts +4 -1
  38. package/dist/cli/handlers/run.d.ts.map +1 -1
  39. package/dist/cli/handlers/run.js +17 -6
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +41 -2
  42. package/dist/cli/parse-args.d.ts.map +1 -1
  43. package/dist/cli/parse-args.js +31 -2
  44. package/dist/cli/registry.d.ts +12 -2
  45. package/dist/cli/registry.d.ts.map +1 -1
  46. package/dist/components/auto-release.d.ts +35 -3
  47. package/dist/components/auto-release.d.ts.map +1 -1
  48. package/dist/components/auto-release.js +29 -2
  49. package/dist/components/cli-support.d.ts +2 -0
  50. package/dist/components/cli-support.d.ts.map +1 -1
  51. package/dist/components/cli-support.js +2 -0
  52. package/dist/components/deploy-units.d.ts.map +1 -1
  53. package/dist/components/deploy-units.js +5 -0
  54. package/dist/components/fan-out-run.d.ts +28 -0
  55. package/dist/components/fan-out-run.d.ts.map +1 -1
  56. package/dist/components/fan-out-run.js +27 -3
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +72 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +96 -5
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts.map +1 -1
  70. package/dist/components/pr-run.js +5 -3
  71. package/dist/components/promote.d.ts.map +1 -1
  72. package/dist/components/promote.js +39 -16
  73. package/dist/gate-decision.d.ts +15 -2
  74. package/dist/gate-decision.d.ts.map +1 -1
  75. package/dist/gate-decision.js +17 -2
  76. package/dist/gate-policy.d.ts +140 -0
  77. package/dist/gate-policy.d.ts.map +1 -0
  78. package/dist/gate-policy.js +242 -0
  79. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  80. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  86. package/dist/lifecycle/publish-declaration.js +4 -3
  87. package/dist/lifecycle/publish-npm.d.ts +2 -2
  88. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  89. package/dist/lifecycle/publish-npm.js +31 -8
  90. package/dist/lifecycle/query.d.ts +68 -0
  91. package/dist/lifecycle/query.d.ts.map +1 -0
  92. package/dist/lifecycle/query.js +246 -0
  93. package/dist/lifecycle/release-ledger.d.ts +31 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +19 -1
  96. package/dist/lifecycle/run-ledger.d.ts +13 -0
  97. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  98. package/dist/lifecycle/run-ledger.js +16 -0
  99. package/dist/lifecycle/status.d.ts +5 -1
  100. package/dist/lifecycle/status.d.ts.map +1 -1
  101. package/dist/lifecycle/status.js +10 -5
  102. package/dist/lifecycle/target-lock.d.ts +97 -0
  103. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  104. package/dist/lifecycle/target-lock.js +286 -0
  105. package/dist/live-endpoint.d.ts +18 -0
  106. package/dist/live-endpoint.d.ts.map +1 -1
  107. package/dist/live-endpoint.js +27 -0
  108. package/dist/op/activities/activity-contracts.d.ts +30 -0
  109. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  110. package/dist/op/activities/activity-contracts.js +26 -0
  111. package/dist/op/activities/index.d.ts +2 -0
  112. package/dist/op/activities/index.d.ts.map +1 -1
  113. package/dist/op/activities/index.js +4 -0
  114. package/dist/op/activities/review-agent.d.ts +137 -0
  115. package/dist/op/activities/review-agent.d.ts.map +1 -0
  116. package/dist/op/activities/review-agent.js +266 -0
  117. package/dist/op/builders.d.ts +9 -0
  118. package/dist/op/builders.d.ts.map +1 -1
  119. package/dist/op/builders.js +11 -0
  120. package/dist/op/cron.d.ts +8 -0
  121. package/dist/op/cron.d.ts.map +1 -1
  122. package/dist/op/cron.js +51 -0
  123. package/dist/op/gate-approval.d.ts +20 -13
  124. package/dist/op/gate-approval.d.ts.map +1 -1
  125. package/dist/op/gate-approval.js +13 -18
  126. package/dist/op/gate-quorum.d.ts +131 -0
  127. package/dist/op/gate-quorum.d.ts.map +1 -0
  128. package/dist/op/gate-quorum.js +183 -0
  129. package/dist/op/gate-resume.d.ts +4 -2
  130. package/dist/op/gate-resume.d.ts.map +1 -1
  131. package/dist/op/gate-resume.js +5 -4
  132. package/dist/op/gate-review.js +1 -1
  133. package/dist/op/gate.d.ts +32 -4
  134. package/dist/op/gate.d.ts.map +1 -1
  135. package/dist/op/gate.js +40 -24
  136. package/dist/op/index.d.ts +6 -4
  137. package/dist/op/index.d.ts.map +1 -1
  138. package/dist/op/index.js +4 -3
  139. package/dist/op/local-executor.d.ts.map +1 -1
  140. package/dist/op/local-executor.js +9 -3
  141. package/dist/op/op-waves-run.d.ts +62 -1
  142. package/dist/op/op-waves-run.d.ts.map +1 -1
  143. package/dist/op/op-waves-run.js +190 -11
  144. package/dist/op/op-waves.d.ts +55 -3
  145. package/dist/op/op-waves.d.ts.map +1 -1
  146. package/dist/op/op-waves.js +63 -8
  147. package/dist/op/schedule-overdue.d.ts +57 -0
  148. package/dist/op/schedule-overdue.d.ts.map +1 -0
  149. package/dist/op/schedule-overdue.js +138 -0
  150. package/dist/override-gate.d.ts +2 -2
  151. package/dist/override-gate.d.ts.map +1 -1
  152. package/dist/override-gate.js +1 -1
  153. package/dist/pr-forge.d.ts +36 -0
  154. package/dist/pr-forge.d.ts.map +1 -1
  155. package/dist/pr-forge.js +82 -0
  156. package/dist/pr-loop.d.ts +90 -1
  157. package/dist/pr-loop.d.ts.map +1 -1
  158. package/dist/pr-loop.js +77 -3
  159. package/dist/pr-post.d.ts +72 -0
  160. package/dist/pr-post.d.ts.map +1 -0
  161. package/dist/pr-post.js +134 -0
  162. package/dist/wave-blocked.d.ts +79 -0
  163. package/dist/wave-blocked.d.ts.map +1 -0
  164. package/dist/wave-blocked.js +245 -0
  165. package/dist/wave-engine.d.ts +6 -0
  166. package/dist/wave-engine.d.ts.map +1 -1
  167. package/dist/wave-engine.js +2 -0
  168. package/dist/wave-runtime.mjs +239 -239
  169. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  170. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  171. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-locks.d.ts +12 -0
  179. package/dist/workspace/ci-locks.d.ts.map +1 -0
  180. package/dist/workspace/ci-locks.js +6 -0
  181. package/dist/workspace/ci-run.d.ts +24 -1
  182. package/dist/workspace/ci-run.d.ts.map +1 -1
  183. package/dist/workspace/ci-run.js +85 -6
  184. package/dist/workspace/declaration.d.ts +63 -0
  185. package/dist/workspace/declaration.d.ts.map +1 -1
  186. package/dist/workspace/declaration.js +72 -1
  187. package/dist/workspace/declaration.schema.json +147 -0
  188. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  189. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  190. package/dist/workspace/identity-gate-rule.js +1 -1
  191. package/dist/workspace/identity.d.ts +1 -1
  192. package/dist/workspace/identity.d.ts.map +1 -1
  193. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  194. package/dist/workspace/lineage-upgrade.js +8 -5
  195. package/dist/workspace/member-pipeline.d.ts +6 -0
  196. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  197. package/dist/workspace/member-pipeline.js +23 -0
  198. package/dist/workspace/pr-report.schema.json +73 -2
  199. package/dist/workspace/security.d.ts.map +1 -1
  200. package/dist/workspace/security.js +4 -0
  201. package/dist/workspace/status-gates.d.ts +3 -2
  202. package/dist/workspace/status-gates.d.ts.map +1 -1
  203. package/dist/workspace/status-gates.js +8 -7
  204. package/dist/workspace/status.d.ts +3 -1
  205. package/dist/workspace/status.d.ts.map +1 -1
  206. package/dist/workspace/status.js +7 -3
  207. package/dist/workspace/status.schema.json +1 -0
  208. package/dist/workspace/trust/seal.d.ts +2 -2
  209. package/dist/workspace/trust/seal.d.ts.map +1 -1
  210. package/dist/workspace/trust/seal.js +2 -2
  211. package/package.json +1 -1
  212. package/src/__fixtures__/oidc-emulator.ts +118 -0
  213. package/src/ci-handover.test.ts +209 -0
  214. package/src/ci-handover.ts +237 -0
  215. package/src/ci-identity.test.ts +201 -0
  216. package/src/ci-identity.ts +310 -0
  217. package/src/cli/component-endpoint.ts +43 -0
  218. package/src/cli/handlers/approve.ts +18 -5
  219. package/src/cli/handlers/build.ts +15 -1
  220. package/src/cli/handlers/fan-out.test.ts +10 -0
  221. package/src/cli/handlers/fan-out.ts +29 -4
  222. package/src/cli/handlers/gate-resume.ts +16 -2
  223. package/src/cli/handlers/lock.test.ts +98 -0
  224. package/src/cli/handlers/lock.ts +202 -0
  225. package/src/cli/handlers/operator.test.ts +3 -1
  226. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  227. package/src/cli/handlers/pr-env.ts +462 -0
  228. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  229. package/src/cli/handlers/pr.test.ts +184 -0
  230. package/src/cli/handlers/pr.ts +508 -64
  231. package/src/cli/handlers/promote.ts +25 -2
  232. package/src/cli/handlers/query.ts +78 -0
  233. package/src/cli/handlers/run.test.ts +2 -1
  234. package/src/cli/handlers/run.ts +16 -5
  235. package/src/cli/main.ts +41 -2
  236. package/src/cli/parse-args.ts +23 -2
  237. package/src/cli/registry.ts +12 -2
  238. package/src/components/auto-release-infra.test.ts +131 -0
  239. package/src/components/auto-release.test.ts +40 -1
  240. package/src/components/auto-release.ts +50 -4
  241. package/src/components/cli-support.ts +4 -0
  242. package/src/components/deploy-units.ts +5 -0
  243. package/src/components/fan-out-run.ts +39 -2
  244. package/src/components/fan-out-waves.test.ts +45 -1
  245. package/src/components/pr-environment.test.ts +249 -0
  246. package/src/components/pr-environment.ts +538 -0
  247. package/src/components/pr-hooks.test.ts +118 -0
  248. package/src/components/pr-hooks.ts +157 -0
  249. package/src/components/pr-pipeline.ts +130 -5
  250. package/src/components/pr-policy.test.ts +145 -0
  251. package/src/components/pr-policy.ts +211 -0
  252. package/src/components/pr-run.ts +5 -2
  253. package/src/components/promote.test.ts +39 -0
  254. package/src/components/promote.ts +38 -14
  255. package/src/gate-decision-bundle.test.ts +4 -3
  256. package/src/gate-decision.ts +39 -2
  257. package/src/gate-policy-bundle.test.ts +28 -0
  258. package/src/gate-policy.test.ts +162 -0
  259. package/src/gate-policy.ts +298 -0
  260. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  261. package/src/lifecycle/gate-checkpoint.ts +1 -1
  262. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  263. package/src/lifecycle/publish-declaration.ts +4 -3
  264. package/src/lifecycle/publish-npm.ts +33 -8
  265. package/src/lifecycle/publish-targets.test.ts +36 -3
  266. package/src/lifecycle/query.test.ts +91 -0
  267. package/src/lifecycle/query.ts +315 -0
  268. package/src/lifecycle/release-ledger.ts +42 -1
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/status.test.ts +19 -0
  271. package/src/lifecycle/status.ts +14 -6
  272. package/src/lifecycle/target-lock.test.ts +195 -0
  273. package/src/lifecycle/target-lock.ts +347 -0
  274. package/src/live-endpoint.ts +31 -0
  275. package/src/op/activities/activity-contracts.ts +31 -0
  276. package/src/op/activities/index.ts +6 -0
  277. package/src/op/activities/review-agent.test.ts +240 -0
  278. package/src/op/activities/review-agent.ts +355 -0
  279. package/src/op/builders.ts +13 -0
  280. package/src/op/cron.ts +44 -0
  281. package/src/op/gate-alias.test.ts +2 -2
  282. package/src/op/gate-approval.test.ts +30 -9
  283. package/src/op/gate-approval.ts +23 -26
  284. package/src/op/gate-quorum.test.ts +139 -0
  285. package/src/op/gate-quorum.ts +248 -0
  286. package/src/op/gate-resume.ts +6 -3
  287. package/src/op/gate-review.ts +1 -1
  288. package/src/op/gate.ts +64 -23
  289. package/src/op/index.ts +13 -1
  290. package/src/op/local-executor.ts +9 -2
  291. package/src/op/op-waves-hook-targets.test.ts +126 -0
  292. package/src/op/op-waves-policy-hold.test.ts +66 -0
  293. package/src/op/op-waves-quorum.test.ts +101 -0
  294. package/src/op/op-waves-run.ts +233 -11
  295. package/src/op/op-waves.ts +83 -10
  296. package/src/op/schedule-overdue.test.ts +220 -0
  297. package/src/op/schedule-overdue.ts +161 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +114 -0
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +146 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/wave-blocked-bundle.test.ts +25 -0
  309. package/src/wave-blocked.test.ts +146 -0
  310. package/src/wave-blocked.ts +271 -0
  311. package/src/wave-engine.ts +8 -0
  312. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  313. package/src/workspace/ci-branches.test.ts +62 -0
  314. package/src/workspace/ci-branches.ts +79 -0
  315. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  316. package/src/workspace/ci-cloud-identity.ts +23 -0
  317. package/src/workspace/ci-locks.ts +16 -0
  318. package/src/workspace/ci-run-index.test.ts +2 -1
  319. package/src/workspace/ci-run.test.ts +133 -8
  320. package/src/workspace/ci-run.ts +92 -7
  321. package/src/workspace/declaration.schema.json +147 -0
  322. package/src/workspace/declaration.test.ts +76 -0
  323. package/src/workspace/declaration.ts +134 -2
  324. package/src/workspace/identity-gate-rule.ts +1 -1
  325. package/src/workspace/identity.ts +1 -1
  326. package/src/workspace/lineage-upgrade.ts +11 -8
  327. package/src/workspace/member-pipeline.test.ts +29 -0
  328. package/src/workspace/member-pipeline.ts +30 -0
  329. package/src/workspace/pr-report.schema.json +73 -2
  330. package/src/workspace/security.ts +4 -0
  331. package/src/workspace/status-gates.ts +9 -6
  332. package/src/workspace/status.schema.json +1 -0
  333. package/src/workspace/status.ts +11 -4
  334. package/src/workspace/trust/gate-seal.test.ts +1 -1
  335. package/src/workspace/trust/seal.ts +2 -2
  336. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  337. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { LedgerReadPort } from "./ledger-port";
3
+ import { firstWord, formatQueryResult, loadQueryData, QUERY_SCHEMA_VERSION, QUERY_TABLES, QueryError, runQuery, SCHEMA_TABLE } from "./query";
4
+ import { makeRelease } from "./release-store";
5
+
6
+ /** A port over a map of `<dir>/<file>` to text, the shape `gitLedgerReadPort` reads. */
7
+ function port(files: Record<string, string>): LedgerReadPort {
8
+ const dirs = (): string[] => [...new Set(Object.keys(files).map((p) => p.slice(0, p.indexOf("/"))))];
9
+ return {
10
+ read: async (dir, file) => files[`${dir}/${file}`] ?? null,
11
+ list: async (dir) => Object.keys(files).filter((p) => p.startsWith(`${dir}/`)).map((p) => p.slice(dir.length + 1)),
12
+ trees: async () => dirs(),
13
+ };
14
+ }
15
+
16
+ const lines = (...rows: unknown[]): string => rows.map((r) => (typeof r === "string" ? r : JSON.stringify(r))).join("\n") + "\n";
17
+
18
+ const release = makeRelease({ member: null, commit: "c".repeat(40), artifactDigests: [{ component: "api", kind: "image", path: "dist/api.tar", digest: "sha256:aa" }], targets: [{ env: "prod", templates: [], params: [] }] });
19
+
20
+ const ledger = port({
21
+ "prod/aws.json": JSON.stringify({ lexicon: "aws", environment: "prod", commit: "a".repeat(40), timestamp: "2026-10-01T00:00:00Z", resources: { Bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "my-bucket", attributes: { Arn: "arn:aws:s3:::my-bucket" } }, Queue: { type: "AWS::SQS::Queue", status: "CREATE_COMPLETE" } } }),
22
+ "prod/edge__k8s.json": JSON.stringify({ lexicon: "k8s", environment: "prod", commit: "b".repeat(40), timestamp: "2026-10-02T00:00:00Z", depth: "deep", resources: { Web: { type: "K8s::Apps::Deployment", status: "Available", ownership: "owned" } }, unobserved: { Gone: { reason: "not-found" } } }),
23
+ "prod/observation-baseline.json": JSON.stringify({ version: 1, environment: "prod" }),
24
+ "prod/releases.jsonl": lines(
25
+ { version: 1, component: "api", env: "prod", digest: "sha256:aa", gitSha: "c".repeat(40), runId: "r1", runOrigin: { forge: "github", url: "https://example.test/run/1" }, timestamp: "2026-10-03T00:00:00Z", actor: "ci", releaseDigest: release.releaseDigest },
26
+ "{not json",
27
+ ),
28
+ "prod/runs__deploy.jsonl": lines(
29
+ { version: 1, id: "run-1", op: "deploy", env: "prod", started: "2026-10-03T00:00:00Z", ended: "2026-10-03T00:01:00Z", status: "fail", labels: { Env: "prod" }, outcomes: {}, phases: [], failure: { code: "command-failed", reason: "exit 1", phase: "apply" } },
30
+ { version: 1, id: "run-2", op: "deploy", env: "prod", started: "2026-10-04T00:00:00Z", ended: "2026-10-04T00:01:00Z", status: "ok", labels: {}, outcomes: { url: "https://x" }, phases: [] },
31
+ ),
32
+ "_gates/deploy.jsonl": lines(
33
+ { version: 1, kind: "pending", op: "deploy", gate: "prod-approval", timestamp: "2026-10-03T00:00:00Z", expiresAt: "2026-10-10T00:00:00Z", planDigest: "sha256:p1", origin: "cli" },
34
+ { version: 1, op: "deploy", gate: "prod-approval", resolvedBy: "alice", timestamp: "2026-10-03T01:00:00Z", planDigest: "sha256:p1", approver: { kind: "human" } },
35
+ ),
36
+ [`_releases/${release.releaseDigest.replace(/:/g, "_")}.json`]: JSON.stringify(release),
37
+ "_releases/sha256_tampered.json": JSON.stringify({ ...release, releaseDigest: "sha256:tampered" }),
38
+ "_releases/cuts.jsonl": lines({ version: 1, releaseDigest: release.releaseDigest, label: "v1.0.0", actor: "alice", timestamp: "2026-10-02T12:00:00Z", runId: null, runOrigin: null, signature: null }),
39
+ });
40
+
41
+ describe("chant query (#4297)", () => {
42
+ test("loads every ledger into its table and counts what it can't read", async () => {
43
+ const load = await loadQueryData([{ member: null, port: ledger }]);
44
+ expect(load.data.snapshots).toHaveLength(2);
45
+ expect(load.data.resources.map((r) => r.name)).toEqual(["Bucket", "Queue", "Web"]);
46
+ expect(load.data.resources[2]).toMatchObject({ stack: "edge", lexicon: "k8s", ownership: "owned" });
47
+ expect(load.data.snapshots[1]).toMatchObject({ depth: "deep", resources: 1, unobserved: 1 });
48
+ expect(load.data.runs.map((r) => r.status)).toEqual(["fail", "ok"]);
49
+ expect(load.data.gates.map((g) => g.kind)).toEqual(["pending", "resolution"]);
50
+ expect(load.data.deploys).toHaveLength(1);
51
+ expect(load.data.releases).toEqual([expect.objectContaining({ release_digest: release.releaseDigest, envs: "prod" })]);
52
+ expect(load.data.cuts[0]).toMatchObject({ label: "v1.0.0", signed: 0 });
53
+ expect(load.malformed).toMatchObject({ deploys: 1, releases: 1, runs: 0, gates: 0 });
54
+ });
55
+
56
+ test("runs SQL over the tables and joins them", async () => {
57
+ const load = await loadQueryData([{ member: null, port: ledger }]);
58
+ const byType = await runQuery("SELECT lexicon, count(*) AS n FROM resources GROUP BY lexicon ORDER BY lexicon", load);
59
+ expect(byType.columns).toEqual(["lexicon", "n"]);
60
+ expect(byType.rows).toEqual([{ lexicon: "aws", n: 2 }, { lexicon: "k8s", n: 1 }]);
61
+ const joined = await runQuery("SELECT d.component, c.label FROM deploys d JOIN cuts c ON c.release_digest = d.release_digest", load);
62
+ expect(joined.rows).toEqual([{ component: "api", label: "v1.0.0" }]);
63
+ const arn = await runQuery("SELECT json_extract(attributes, '$.Arn') AS arn FROM resources WHERE name = 'Bucket'", load);
64
+ expect(arn.rows[0].arn).toBe("arn:aws:s3:::my-bucket");
65
+ expect(arn.schemaVersion).toBe(QUERY_SCHEMA_VERSION);
66
+ expect(formatQueryResult(arn)).toContain("(1 row)");
67
+ });
68
+
69
+ test("chant_schema lists every column with the schema version", async () => {
70
+ const load = await loadQueryData([]);
71
+ const r = await runQuery(`SELECT DISTINCT version, table_name FROM ${SCHEMA_TABLE} ORDER BY table_name`, load);
72
+ expect(r.rows.map((x) => x.table_name)).toEqual(Object.keys(QUERY_TABLES).sort());
73
+ expect(new Set(r.rows.map((x) => x.version))).toEqual(new Set([QUERY_SCHEMA_VERSION]));
74
+ });
75
+
76
+ test("each member's rows name the member", async () => {
77
+ const load = await loadQueryData([{ member: "api", port: ledger }, { member: "web", port: port({}) }]);
78
+ const r = await runQuery("SELECT DISTINCT member FROM runs", load);
79
+ expect(r.rows).toEqual([{ member: "api" }]);
80
+ });
81
+
82
+ test("refuses anything but a read", async () => {
83
+ const load = await loadQueryData([]);
84
+ await expect(runQuery("DELETE FROM runs", load)).rejects.toBeInstanceOf(QueryError);
85
+ await expect(runQuery(" ", load)).rejects.toBeInstanceOf(QueryError);
86
+ // A WITH that writes is stopped by query_only.
87
+ await expect(runQuery("WITH x AS (SELECT 1) INSERT INTO runs (id) SELECT * FROM x", load)).rejects.toBeInstanceOf(QueryError);
88
+ await expect(runQuery("SELECT nope FROM runs", load)).rejects.toThrow(/does not run/);
89
+ expect(firstWord("-- note\n /* c */ select 1")).toBe("select");
90
+ });
91
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * `chant query "<sql>"` (#4297): one read-only SQL statement over what
3
+ * `chant/lifecycle` holds, run in this process with no server.
4
+ *
5
+ * The rows come from the ledgers chant already writes:
6
+ *
7
+ * - the snapshots, `<env>/<lexicon>.json` and `<env>/<stack>__<lexicon>.json`
8
+ * (`./git.ts` `snapshotStorageKey`), each lexicon's `describeResources`
9
+ * read, so the inventory works for every lexicon;
10
+ * - the Op run ledgers, `<env>/runs__<op>.jsonl` (`./run-ledger.ts`);
11
+ * - the gate ledgers, `_gates/<op>.jsonl` (`./gate-ledger.ts`);
12
+ * - the deploy records, `<env>/releases.jsonl` (`./release-read.ts`);
13
+ * - the releases and their cuts, `_releases/<digest>.json` and
14
+ * `_releases/cuts.jsonl` (`./release-store.ts`).
15
+ *
16
+ * The tables are a contract. {@link QUERY_SCHEMA_VERSION} names it, and the
17
+ * `chant_schema` table lists every table and column with the version, so a
18
+ * saved query can check what it runs against. A column is only added within
19
+ * a version; renaming or removing one, or changing what it holds, is a new
20
+ * version.
21
+ *
22
+ * Everything but {@link runQuery} is pure over a {@link LedgerReadPort}: the
23
+ * caller decides which members to read and how.
24
+ *
25
+ * The engine is `node:sqlite`, the SQLite built into Node.js 22.13 and later.
26
+ * The database is in memory, takes `PRAGMA query_only`, and only a statement
27
+ * that begins with SELECT, WITH or VALUES runs. This mirrors terragucci's
28
+ * `query.ts`, which reads report JSON from a bucket instead.
29
+ */
30
+ import type { GateOrigin } from "./gate-origin";
31
+ import { parseGateLedger, GATES_DIR, type GateResolutionRecord, type PendingGateRecord } from "./gate-ledger";
32
+ import type { LedgerReadPort } from "./ledger-port";
33
+ import { parseReleaseLedger, releaseLedgerLines } from "./release-read";
34
+ import { CUTS_FILE, parseCuts, parseRelease, RELEASES_DIR, verifyReleaseDigest } from "./release-store";
35
+ import { opOfRunLedgerFile, parseRunLedger } from "./run-ledger";
36
+ import type { LifecycleSnapshot } from "./types";
37
+
38
+ /** The version of the table schema below. */
39
+ export const QUERY_SCHEMA_VERSION = 1;
40
+
41
+ type ColumnType = "TEXT" | "INTEGER";
42
+
43
+ /**
44
+ * Each table's columns, in order, with their SQLite type. A column named in
45
+ * the doc as JSON holds a JSON text, for `json_extract`. Every table starts
46
+ * with `member`: the workspace member whose ledger the row came from, or NULL
47
+ * for a project outside a workspace.
48
+ */
49
+ export const QUERY_TABLES = {
50
+ /** One row per resource in each environment's newest snapshot. `attributes` is JSON. */
51
+ resources: [["member", "TEXT"], ["env", "TEXT"], ["stack", "TEXT"], ["lexicon", "TEXT"], ["name", "TEXT"], ["type", "TEXT"], ["physical_id", "TEXT"], ["status", "TEXT"], ["last_updated", "TEXT"], ["ownership", "TEXT"], ["attributes", "TEXT"], ["git_sha", "TEXT"], ["observed_at", "TEXT"]],
52
+ /** One row per snapshot file: what was observed, when, and how many resources it holds. */
53
+ snapshots: [["member", "TEXT"], ["env", "TEXT"], ["stack", "TEXT"], ["lexicon", "TEXT"], ["git_sha", "TEXT"], ["observed_at", "TEXT"], ["depth", "TEXT"], ["resources", "INTEGER"], ["unobserved", "INTEGER"]],
54
+ /** One row per Op run record. `labels` and `outcomes` are JSON. */
55
+ runs: [["member", "TEXT"], ["id", "TEXT"], ["op", "TEXT"], ["env", "TEXT"], ["started", "TEXT"], ["ended", "TEXT"], ["status", "TEXT"], ["git_sha", "TEXT"], ["forge", "TEXT"], ["run_url", "TEXT"], ["failure_code", "TEXT"], ["failure_reason", "TEXT"], ["failed_phase", "TEXT"], ["gate", "TEXT"], ["steward", "TEXT"], ["labels", "TEXT"], ["outcomes", "TEXT"]],
56
+ /** One row per gate ledger line: `kind` is `pending` or `resolution`. `signed` is 1 for a sealed approval. */
57
+ gates: [["member", "TEXT"], ["op", "TEXT"], ["gate", "TEXT"], ["kind", "TEXT"], ["timestamp", "TEXT"], ["resolved_by", "TEXT"], ["relayed_by", "TEXT"], ["approver_kind", "TEXT"], ["plan_digest", "TEXT"], ["environment", "TEXT"], ["release_digest", "TEXT"], ["git_sha", "TEXT"], ["run_id", "TEXT"], ["url", "TEXT"], ["note", "TEXT"], ["expires_at", "TEXT"], ["origin", "TEXT"], ["empty", "INTEGER"], ["signed", "INTEGER"]],
58
+ /** One row per deploy record in `<env>/releases.jsonl`. `promoted_from`, `restores` and `redeploys` are JSON. */
59
+ deploys: [["member", "TEXT"], ["env", "TEXT"], ["component", "TEXT"], ["digest", "TEXT"], ["git_sha", "TEXT"], ["run_id", "TEXT"], ["forge", "TEXT"], ["run_url", "TEXT"], ["timestamp", "TEXT"], ["actor", "TEXT"], ["approver", "TEXT"], ["release_digest", "TEXT"], ["digest_kind", "TEXT"], ["promoted_from", "TEXT"], ["restores", "TEXT"], ["redeploys", "TEXT"]],
60
+ /** One row per release file whose content hashes to its digest. `envs` joins its targets' environments with commas; `artifacts` is JSON. */
61
+ releases: [["member", "TEXT"], ["release_digest", "TEXT"], ["release_member", "TEXT"], ["git_sha", "TEXT"], ["envs", "TEXT"], ["artifacts", "TEXT"]],
62
+ /** One row per line of `_releases/cuts.jsonl`. `signed` is 1 for a signed cut. */
63
+ cuts: [["member", "TEXT"], ["release_digest", "TEXT"], ["label", "TEXT"], ["actor", "TEXT"], ["timestamp", "TEXT"], ["run_id", "TEXT"], ["forge", "TEXT"], ["channel", "TEXT"], ["signed", "INTEGER"], ["composed_base", "TEXT"]],
64
+ } as const satisfies Record<string, ReadonlyArray<readonly [string, ColumnType]>>;
65
+
66
+ export type QueryTable = keyof typeof QUERY_TABLES;
67
+
68
+ /** The table that describes the others: one row per column, with the schema version. */
69
+ export const SCHEMA_TABLE = "chant_schema";
70
+
71
+ type Value = string | number | null;
72
+ export type QueryRow = Record<string, Value>;
73
+
74
+ /** The rows of each table, as loaded. */
75
+ export type QueryData = Record<QueryTable, QueryRow[]>;
76
+
77
+ /** One ledger to read: a member's, or the project's own with `member` null. */
78
+ export interface QueryLedger {
79
+ member: string | null;
80
+ port: LedgerReadPort;
81
+ }
82
+
83
+ export interface QueryLoad {
84
+ data: QueryData;
85
+ /** Lines and files that could not be read as their kind, per table. Left out of the rows. */
86
+ malformed: Record<QueryTable, number>;
87
+ }
88
+
89
+ export interface QueryResult {
90
+ schemaVersion: number;
91
+ columns: string[];
92
+ rows: QueryRow[];
93
+ /** How many rows each table held. */
94
+ tables: Record<QueryTable, number>;
95
+ malformed: Record<QueryTable, number>;
96
+ }
97
+
98
+ /** A statement that can't run, or a Node.js with no SQLite. The message is for a person. */
99
+ export class QueryError extends Error {}
100
+
101
+ const TABLE_NAMES = Object.keys(QUERY_TABLES) as QueryTable[];
102
+ const empty = <T>(v: () => T): Record<QueryTable, T> => Object.fromEntries(TABLE_NAMES.map((t) => [t, v()])) as Record<QueryTable, T>;
103
+ const json = (v: unknown): string | null => (v === undefined || v === null ? null : JSON.stringify(v));
104
+ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
105
+ const bit = (v: boolean): number => (v ? 1 : 0);
106
+
107
+ /** Whether a parsed `.json` file under an environment is a snapshot (an observation baseline or another file is not). */
108
+ function isSnapshot(v: unknown): v is LifecycleSnapshot {
109
+ if (!v || typeof v !== "object") return false;
110
+ const s = v as Partial<LifecycleSnapshot>;
111
+ return typeof s.lexicon === "string" && typeof s.environment === "string" && typeof s.commit === "string" && typeof s.timestamp === "string" && !!s.resources && typeof s.resources === "object" && !Array.isArray(s.resources);
112
+ }
113
+
114
+ function snapshotRows(member: string | null, env: string, file: string, s: LifecycleSnapshot, data: QueryData): void {
115
+ // The file name folds the stack in as `<stack>__<lexicon>` (git.ts snapshotStorageKey); the record names it too.
116
+ const key = file.slice(0, -".json".length);
117
+ const stack = s.stack ?? (key.includes("__") ? key.slice(0, key.lastIndexOf("__")) : null);
118
+ const at = { git_sha: s.commit, observed_at: s.timestamp };
119
+ const resources = Object.entries(s.resources);
120
+ data.snapshots.push({ member, env, stack, lexicon: s.lexicon, ...at, depth: s.depth ?? "identity", resources: resources.length, unobserved: Object.keys(s.unobserved ?? {}).length });
121
+ for (const [name, r] of resources) {
122
+ data.resources.push({ member, env, stack, lexicon: s.lexicon, name, type: str(r?.type), physical_id: str(r?.physicalId), status: str(r?.status), last_updated: str(r?.lastUpdated), ownership: str(r?.ownership), attributes: json(r?.attributes), ...at });
123
+ }
124
+ }
125
+
126
+ function gateRow(member: string | null, r: GateResolutionRecord | PendingGateRecord): QueryRow {
127
+ const pending = r.kind === "pending";
128
+ const res = pending ? undefined : (r as GateResolutionRecord);
129
+ const origin: GateOrigin | undefined = (r as { origin?: GateOrigin }).origin;
130
+ return {
131
+ member,
132
+ op: r.op,
133
+ gate: r.gate,
134
+ kind: pending ? "pending" : "resolution",
135
+ timestamp: r.timestamp,
136
+ resolved_by: res?.resolvedBy ?? null,
137
+ relayed_by: str(res?.relayedBy),
138
+ approver_kind: str(res?.approver?.kind),
139
+ plan_digest: str(r.planDigest),
140
+ environment: str(r.environment),
141
+ release_digest: str(r.releaseDigest),
142
+ git_sha: str(r.gitSha),
143
+ run_id: str(r.runId),
144
+ url: str(res?.url),
145
+ note: str(res?.note),
146
+ expires_at: pending ? str((r as PendingGateRecord).expiresAt) : null,
147
+ origin: str(origin),
148
+ empty: bit(res?.empty === true),
149
+ signed: bit(res?.seal !== undefined),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Read every table's rows from the ledgers, in the order given. A ledger
155
+ * whose branch is absent reads as no rows. A line or file that is not its
156
+ * kind is counted in `malformed` and left out, as each ledger's own reader
157
+ * does.
158
+ */
159
+ export async function loadQueryData(ledgers: readonly QueryLedger[]): Promise<QueryLoad> {
160
+ const data = empty<QueryRow[]>(() => []) as QueryData;
161
+ const malformed = empty(() => 0);
162
+ for (const { member, port } of ledgers) {
163
+ const trees = (await port.trees()).sort();
164
+ for (const env of trees.filter((t) => !t.startsWith("_"))) {
165
+ for (const file of (await port.list(env)).sort()) {
166
+ if (file.endsWith(".json")) {
167
+ const text = await port.read(env, file);
168
+ let parsed: unknown;
169
+ try {
170
+ parsed = text === null ? null : JSON.parse(text);
171
+ } catch {
172
+ parsed = null;
173
+ }
174
+ // Other JSON files live beside the snapshots (the observation baseline); only a snapshot-shaped one is read.
175
+ if (isSnapshot(parsed)) snapshotRows(member, env, file, parsed, data);
176
+ continue;
177
+ }
178
+ if (file === "releases.jsonl") {
179
+ const { records, malformed: bad } = parseReleaseLedger(releaseLedgerLines(await port.read(env, file)));
180
+ malformed.deploys += bad;
181
+ for (const d of records) {
182
+ data.deploys.push({ member, env: d.env, component: d.component, digest: d.digest, git_sha: d.gitSha, run_id: d.runId, forge: str(d.runOrigin?.forge), run_url: str(d.runOrigin?.url), timestamp: d.timestamp, actor: d.actor, approver: str(d.approver), release_digest: str(d.releaseDigest), digest_kind: str(d.digestKind), promoted_from: json(d.promotedFrom), restores: json(d.restores), redeploys: json(d.redeploys) });
183
+ }
184
+ continue;
185
+ }
186
+ if (opOfRunLedgerFile(file) !== null) {
187
+ const text = await port.read(env, file);
188
+ if (text === null) continue;
189
+ const { records, malformed: bad } = parseRunLedger(text);
190
+ malformed.runs += bad;
191
+ for (const r of records) {
192
+ data.runs.push({ member, id: r.id, op: r.op, env: r.env, started: r.started, ended: r.ended, status: r.status, git_sha: str(r.gitSha), forge: str(r.runOrigin?.forge), run_url: str(r.runOrigin?.url), failure_code: str(r.failure?.code), failure_reason: str(r.failure?.reason), failed_phase: str(r.failure?.phase), gate: str(r.gate?.name), steward: str(r.steward), labels: json(r.labels ?? {}), outcomes: json(r.outcomes ?? {}) });
193
+ }
194
+ }
195
+ }
196
+ }
197
+ if (trees.includes(GATES_DIR)) {
198
+ for (const file of (await port.list(GATES_DIR)).sort()) {
199
+ if (!file.endsWith(".jsonl")) continue;
200
+ const text = await port.read(GATES_DIR, file);
201
+ if (text === null) continue;
202
+ const { resolutions, pending, malformed: bad } = parseGateLedger(text);
203
+ malformed.gates += bad;
204
+ // Both kinds share the file; ledger order is time order, so they are merged back by timestamp.
205
+ const lines = [...pending, ...resolutions].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
206
+ for (const r of lines) data.gates.push(gateRow(member, r));
207
+ }
208
+ }
209
+ if (trees.includes(RELEASES_DIR)) {
210
+ for (const file of (await port.list(RELEASES_DIR)).sort()) {
211
+ if (file === CUTS_FILE) {
212
+ const { cuts, malformed: bad } = parseCuts(await port.read(RELEASES_DIR, file));
213
+ malformed.cuts += bad;
214
+ for (const c of cuts) {
215
+ data.cuts.push({ member, release_digest: c.releaseDigest, label: c.label, actor: c.actor, timestamp: c.timestamp, run_id: c.runId, forge: str(c.runOrigin?.forge), channel: str(c.channel), signed: bit(c.signature !== null), composed_base: str(c.composed?.base) });
216
+ }
217
+ continue;
218
+ }
219
+ if (!file.endsWith(".json")) continue;
220
+ const text = await port.read(RELEASES_DIR, file);
221
+ const release = text === null ? null : parseRelease(text);
222
+ // `readAllReleases` (release-store.ts) counts a file whose content does not hash to its digest as malformed; so does this.
223
+ if (!release || release.releaseDigest.replace(/:/g, "_") !== file.slice(0, -".json".length) || !verifyReleaseDigest(release)) {
224
+ malformed.releases++;
225
+ continue;
226
+ }
227
+ data.releases.push({ member, release_digest: release.releaseDigest, release_member: release.member, git_sha: release.commit, envs: release.targets.map((t) => t.env).join(","), artifacts: json(release.artifactDigests) });
228
+ }
229
+ }
230
+ }
231
+ return { data, malformed };
232
+ }
233
+
234
+ /** The part of `node:sqlite` this uses, typed here since the repo's @types/node predates the module. */
235
+ interface SqliteStatement {
236
+ run(...values: Value[]): unknown;
237
+ all(): unknown[];
238
+ columns?(): Array<{ name: string }>;
239
+ }
240
+ interface SqliteModule {
241
+ DatabaseSync: new (path: string) => { exec(sql: string): void; prepare(sql: string): SqliteStatement; close(): void };
242
+ }
243
+
244
+ /** node:sqlite, without the experimental warning some Node.js versions print when it loads. */
245
+ async function sqlite(): Promise<SqliteModule> {
246
+ const emit = process.emitWarning;
247
+ process.emitWarning = ((warning: string | Error, ...rest: unknown[]) => {
248
+ if (/SQLite/.test(typeof warning === "string" ? warning : warning.message)) return;
249
+ return (emit as (...a: unknown[]) => void).call(process, warning, ...rest);
250
+ }) as typeof process.emitWarning;
251
+ try {
252
+ const name = "node:sqlite";
253
+ return (await import(name)) as SqliteModule;
254
+ } catch {
255
+ throw new QueryError(`chant query runs on the SQLite built into Node.js 22.13 or later; this is Node.js ${process.versions.node}`);
256
+ } finally {
257
+ process.emitWarning = emit;
258
+ }
259
+ }
260
+
261
+ /** The first word of a statement, past whitespace and comments. */
262
+ export function firstWord(sql: string): string {
263
+ const s = sql.replace(/^(\s+|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)*/, "");
264
+ return (/^[A-Za-z]+/.exec(s)?.[0] ?? "").toLowerCase();
265
+ }
266
+
267
+ /** Run one read-only statement over the tables. */
268
+ export async function runQuery(sql: string, load: QueryLoad): Promise<QueryResult> {
269
+ if (sql.trim() === "") throw new QueryError('chant query needs a statement: chant query "SELECT * FROM resources"');
270
+ if (!["select", "with", "values"].includes(firstWord(sql))) throw new QueryError("chant query runs one SELECT (or WITH, or VALUES) statement; it changes nothing");
271
+ const { DatabaseSync } = await sqlite();
272
+ const db = new DatabaseSync(":memory:");
273
+ try {
274
+ db.exec(`CREATE TABLE ${SCHEMA_TABLE} ("version" INTEGER, "table_name" TEXT, "column_name" TEXT, "type" TEXT, "position" INTEGER)`);
275
+ const describe = db.prepare(`INSERT INTO ${SCHEMA_TABLE} VALUES (?, ?, ?, ?, ?)`);
276
+ for (const table of TABLE_NAMES) {
277
+ const cols = QUERY_TABLES[table] as ReadonlyArray<readonly [string, ColumnType]>;
278
+ db.exec(`CREATE TABLE ${table} (${cols.map(([c, t]) => `"${c}" ${t}`).join(", ")})`);
279
+ cols.forEach(([c, t], i) => describe.run(QUERY_SCHEMA_VERSION, table, c, t, i + 1));
280
+ const insert = db.prepare(`INSERT INTO ${table} VALUES (${cols.map(() => "?").join(", ")})`);
281
+ db.exec("BEGIN");
282
+ for (const row of load.data[table]) insert.run(...cols.map(([c]) => row[c] ?? null));
283
+ db.exec("COMMIT");
284
+ }
285
+ db.exec("PRAGMA query_only = ON");
286
+ let stmt: SqliteStatement;
287
+ try {
288
+ stmt = db.prepare(sql);
289
+ } catch (e) {
290
+ throw new QueryError(`the query does not run: ${(e as Error).message}`);
291
+ }
292
+ let rows: QueryRow[];
293
+ try {
294
+ rows = stmt.all() as QueryRow[];
295
+ } catch (e) {
296
+ throw new QueryError(`the query does not run: ${(e as Error).message}`);
297
+ }
298
+ const columns = typeof stmt.columns === "function" ? stmt.columns().map((c) => c.name) : Object.keys(rows[0] ?? {});
299
+ const tables = Object.fromEntries(TABLE_NAMES.map((t) => [t, load.data[t].length])) as Record<QueryTable, number>;
300
+ return { schemaVersion: QUERY_SCHEMA_VERSION, columns, rows: rows.map((r) => ({ ...r })), tables, malformed: load.malformed };
301
+ } finally {
302
+ db.close();
303
+ }
304
+ }
305
+
306
+ const cell = (v: Value | undefined): string => (v === null || v === undefined ? "" : String(v));
307
+
308
+ /** The rows as an aligned table, a header and a count. */
309
+ export function formatQueryResult(r: QueryResult): string {
310
+ const widths = r.columns.map((c) => Math.max(c.length, ...r.rows.map((row) => cell(row[c]).length)));
311
+ const line = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
312
+ const out = [line(r.columns), line(widths.map((w) => "-".repeat(w))), ...r.rows.map((row) => line(r.columns.map((c) => cell(row[c]))))];
313
+ out.push(`(${r.rows.length} ${r.rows.length === 1 ? "row" : "rows"})`);
314
+ return out.join("\n");
315
+ }
@@ -242,6 +242,35 @@ export interface ReleaseRecord {
242
242
  * deploy that was not made from a release.
243
243
  */
244
244
  releaseDigest?: string;
245
+ /**
246
+ * Optional: what `digest` names when it is not an artifact digest (#4274).
247
+ * Absent on a record whose digest is the artifact the deploy promoted, and on
248
+ * every record written before 0.124.35.
249
+ *
250
+ * - `plan`: the component published nothing, and the digest is its plan
251
+ * digest, the one its approval bound (`pr-apply`).
252
+ * - `deploy-input`: the component published nothing and planned nothing, and
253
+ * the digest is its deploy-input digest (`componentInputDigests`,
254
+ * ../components/fan-out-inputs.ts).
255
+ * - `templates`: the digest of the templates the release records for the
256
+ * component (#4222, `releaseTemplatesDigest`).
257
+ *
258
+ * A `plan` or `deploy-input` record says the component applied; it names no
259
+ * artifact, so promote, rollback and redeploy never deploy it
260
+ * ({@link isApplyRecord}).
261
+ */
262
+ digestKind?: ReleaseDigestKind;
263
+ }
264
+
265
+ /** What a release record's `digest` names when it is not an artifact digest (#4274). */
266
+ export type ReleaseDigestKind = "plan" | "deploy-input" | "templates";
267
+
268
+ /**
269
+ * Whether `record` only says its component applied (#4274): its digest is a
270
+ * plan or deploy-input digest, not one anything can be deployed from again.
271
+ */
272
+ export function isApplyRecord(record: Pick<ReleaseRecord, "digestKind">): boolean {
273
+ return record.digestKind === "plan" || record.digestKind === "deploy-input";
245
274
  }
246
275
 
247
276
  /** Thrown by `appendReleaseRecord` when the record is missing a required field — a release record can never be recorded partially, since it is never editable afterward. */
@@ -319,9 +348,21 @@ export function resolveCiRun(env: NodeJS.ProcessEnv = process.env): { runId: str
319
348
  },
320
349
  };
321
350
  }
351
+ // #4275: a step of a job the chant runner ran. The forge branches come first, and the runner
352
+ // strips the forge's run variables from its steps, so neither kind of job is taken for the other.
353
+ if (env[CHANT_CI_RUN_ID_ENV] && env[CHANT_CI_RUN_FORGE_ENV] === "chant") {
354
+ return { runId: env[CHANT_CI_RUN_ID_ENV], runOrigin: { forge: "chant" } };
355
+ }
322
356
  return undefined;
323
357
  }
324
358
 
359
+ /** The run id `chant ci run` hands each step of a job it runs (#4275), the id its run index lists. */
360
+ export const CHANT_CI_RUN_ID_ENV = "CHANT_CI_RUN_ID";
361
+ /** The run's origin `chant ci run` hands each step with {@link CHANT_CI_RUN_ID_ENV}: always `chant`. */
362
+ export const CHANT_CI_RUN_FORGE_ENV = "CHANT_CI_RUN_FORGE";
363
+ /** The forge's run variables, which a step of a job the chant runner ran never gets (#4275). */
364
+ export const FORGE_RUN_ENV = ["GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "GITHUB_JOB", "CI_PIPELINE_ID", "CI_PIPELINE_URL", "CI_JOB_ID"] as const;
365
+
325
366
  /**
326
367
  * The CI run the environment names, as {@link resolveCiRun} has it, with the
327
368
  * job within the run and the run's attempt when the forge gives them
@@ -332,7 +373,7 @@ export function resolveCiJob(env: NodeJS.ProcessEnv = process.env): { runId: str
332
373
  const ci = resolveCiRun(env);
333
374
  if (!ci) return undefined;
334
375
  const github = ci.runOrigin.forge === "github";
335
- const jobId = github ? env.GITHUB_JOB : env.CI_JOB_ID;
376
+ const jobId = github ? env.GITHUB_JOB : ci.runOrigin.forge === "gitlab" ? env.CI_JOB_ID : undefined;
336
377
  const attempt = github ? Number(env.GITHUB_RUN_ATTEMPT) : NaN;
337
378
  return {
338
379
  runId: ci.runId,
@@ -197,7 +197,25 @@ export async function readRunLedger(
197
197
  ): Promise<{ records: OpRunRecord[]; malformed: number }> {
198
198
  const content = await readBlobFromPath(environment, fileFor(op), opts);
199
199
  if (!content) return { records: [], malformed: 0 };
200
+ return parseRunLedger(content);
201
+ }
202
+
203
+ /** The run ledger file name for `op` under `<env>/`, `runs__<op>.jsonl`. */
204
+ export function runLedgerFile(op: string): string {
205
+ return fileFor(op);
206
+ }
200
207
 
208
+ /** The op a run ledger file name holds, or null when `file` is not one. */
209
+ export function opOfRunLedgerFile(file: string): string | null {
210
+ return file.startsWith(PREFIX) && file.endsWith(".jsonl") && file.length > PREFIX.length + ".jsonl".length ? file.slice(PREFIX.length, -".jsonl".length) : null;
211
+ }
212
+
213
+ /**
214
+ * Parse the text of one run ledger file the way {@link readRunLedger} reads
215
+ * it (#4297): a reader that fetches the file itself, such as `chant query`,
216
+ * gets the same records and the same malformed count.
217
+ */
218
+ export function parseRunLedger(content: string): { records: OpRunRecord[]; malformed: number } {
201
219
  const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
202
220
  const records: OpRunRecord[] = [];
203
221
  let malformed = 0;
@@ -432,6 +432,16 @@ describe("status", () => {
432
432
  expect(result.digestB).toBe("sha256:prod-bytes");
433
433
  });
434
434
 
435
+ test("apply records compare on the commit, since their digests take in the environment (#4274)", () => {
436
+ const apply = (env: string, gitSha: string) =>
437
+ record({ component: "infra", env, gitSha, digest: `jcs1-sha256:${env.padEnd(64, "0").slice(0, 64)}`, digestKind: "deploy-input" });
438
+ const same = compareAcrossEnvironments("infra", { name: "staging", records: [apply("staging", "a1")] }, { name: "prod", records: [apply("prod", "a1")] });
439
+ expect(same.same).toBe(true);
440
+ expect(same.comparedOn).toEqual({ a: "git:a1", b: "git:a1" });
441
+ const moved = compareAcrossEnvironments("infra", { name: "staging", records: [apply("staging", "b2")] }, { name: "prod", records: [apply("prod", "a1")] });
442
+ expect(moved.same).toBe(false);
443
+ });
444
+
435
445
  test("differing inputDigests are a real difference, whatever the bytes say", () => {
436
446
  const result = compareAcrossEnvironments(
437
447
  "web",
@@ -671,6 +681,15 @@ describe("drifted: live evidence contradicts the recorded release (#2513)", () =
671
681
  expect(row.reconciliation).toBe("reconciled");
672
682
  });
673
683
 
684
+ test("reconciled: an apply record's plan digest is never held against a live digest (#4274)", () => {
685
+ const row = reconcile(
686
+ [entry()],
687
+ new Map([["svc", { now: live({ attributes: { digest: "sha256:other", gitSha: "1111111" } }) }]]),
688
+ { ...released, digest: `jcs1-sha256:${"a".repeat(64)}`, digestKind: "plan" },
689
+ );
690
+ expect(row.reconciliation).toBe("reconciled");
691
+ });
692
+
674
693
  test("drifted: a foreign digest", () => {
675
694
  const row = reconcile([entry()], new Map([["svc", { now: live({ attributes: { digest: "sha256:other" } }) }]]));
676
695
  expect(row.reconciliation).toBe("drifted");
@@ -43,7 +43,7 @@
43
43
 
44
44
  import type { ChangeSet, ChangeAction } from "./change-set";
45
45
  import type { ResourceMetadata } from "../lexicon";
46
- import { latestPerComponent, type ReleaseRecord } from "./release-ledger";
46
+ import { isApplyRecord, latestPerComponent, type ReleaseRecord } from "./release-ledger";
47
47
  import { isLegacyContentDigest } from "./legacy-digest";
48
48
  import type { BuildLedgerEntry, ComponentBomSummary } from "./build-ledger";
49
49
  import { unobservedReasonText, type UnobservedReason } from "../observation";
@@ -647,7 +647,8 @@ function notBefore(since: string, recordedAt: string): boolean {
647
647
  */
648
648
  function contradictsRecord(recorded: ReleaseRecord, evidence: LiveComponentEvidence): string | undefined {
649
649
  for (const id of evidence.identities ?? []) {
650
- if (id.digest !== undefined) {
650
+ // #4274: an apply's digest is a plan or deploy-input digest, which no live resource reports.
651
+ if (id.digest !== undefined && !isApplyRecord(recorded)) {
651
652
  const matches =
652
653
  sameDigest(id.digest, recorded.digest) ||
653
654
  (recorded.inputDigest !== undefined && sameDigest(id.digest, recorded.inputDigest));
@@ -697,7 +698,9 @@ export interface CrossEnvComparison {
697
698
  * environments legitimately render to different bytes, so cross-environment
698
699
  * "is prod running what staging tested" joins on the input side while
699
700
  * `digest` proves the exact bytes each cluster got. Records without it
700
- * compare on `digest` exactly as before.
701
+ * compare on `digest` exactly as before. An apply record (#4274) compares
702
+ * on its `gitSha`, since its plan or deploy-input digest takes in the
703
+ * environment and so differs between any two.
701
704
  */
702
705
  same: boolean;
703
706
  /** The identities `same` was decided on (`inputDigest ?? digest` per side), when both sides had one and they differ from the digests shown. */
@@ -711,6 +714,11 @@ export interface CrossEnvComparison {
711
714
  mixedDigestForms?: true;
712
715
  }
713
716
 
717
+ /** What two environments' records of a component are compared on: the input side when there is one, the commit for an apply (#4274). */
718
+ export function comparableIdentity(record: ReleaseRecord): string {
719
+ return isApplyRecord(record) ? `git:${record.gitSha}` : record.inputDigest ?? record.digest;
720
+ }
721
+
714
722
  /** Compare the latest recorded digest for `component` between two environments' release records. */
715
723
  export function compareAcrossEnvironments(
716
724
  component: string,
@@ -719,9 +727,9 @@ export function compareAcrossEnvironments(
719
727
  ): CrossEnvComparison {
720
728
  const latestA = latestPerComponent(envA.records).get(component);
721
729
  const latestB = latestPerComponent(envB.records).get(component);
722
- const idA = latestA ? latestA.inputDigest ?? latestA.digest : undefined;
723
- const idB = latestB ? latestB.inputDigest ?? latestB.digest : undefined;
724
- const comparedOnInputs = (latestA?.inputDigest ?? latestB?.inputDigest) !== undefined;
730
+ const idA = latestA ? comparableIdentity(latestA) : undefined;
731
+ const idB = latestB ? comparableIdentity(latestB) : undefined;
732
+ const comparedOnInputs = (latestA !== undefined && idA !== latestA.digest) || (latestB !== undefined && idB !== latestB.digest);
725
733
  return {
726
734
  component,
727
735
  envA: envA.name,