hecks 2.9.0 → 3.0.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 (600) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +51 -6
  3. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  6. data/lib/hecks/adapters/driven/memory.rb +16 -3
  7. data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
  8. data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
  9. data/lib/hecks/adapters/driven/postgres.rb +4 -2
  10. data/lib/hecks/adapters/driven/prism.rb +41 -8
  11. data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
  12. data/lib/hecks/behaviors/expectations.rb +1 -1
  13. data/lib/hecks/behaviors.rb +1 -1
  14. data/lib/hecks/bench/cli.rb +62 -0
  15. data/lib/hecks/bench/environment.rb +71 -0
  16. data/lib/hecks/bench/postgres_probe.rb +30 -0
  17. data/lib/hecks/bench/report.rb +76 -0
  18. data/lib/hecks/bench/ruby_runner.rb +93 -0
  19. data/lib/hecks/bench/run.rb +55 -0
  20. data/lib/hecks/bench/rust_runner.rb +101 -0
  21. data/lib/hecks/bench/stats.rb +46 -0
  22. data/lib/hecks/bench/suite.rb +98 -0
  23. data/lib/hecks/bench/workload.rb +89 -0
  24. data/lib/hecks/bench.rb +17 -0
  25. data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
  26. data/lib/hecks/bluebook/attribute.rb +1 -1
  27. data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
  28. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
  29. data/lib/hecks/bluebook/chapter.rb +6 -2
  30. data/lib/hecks/bluebook/domain_port.rb +36 -5
  31. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
  32. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
  33. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
  34. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
  35. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
  36. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
  37. data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
  39. data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
  40. data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
  41. data/lib/hecks/bluebook/expression/resolver.rb +3 -2
  42. data/lib/hecks/bluebook/hexagon.rb +13 -5
  43. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
  44. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
  45. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
  46. data/lib/hecks/bluebook/meta_validator.rb +9 -4
  47. data/lib/hecks/bluebook/model_check.rb +23 -0
  48. data/lib/hecks/bluebook/pattern_subset.rb +38 -0
  49. data/lib/hecks/bluebook/query.rb +20 -5
  50. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  51. data/lib/hecks/canonical_json.rb +30 -0
  52. data/lib/hecks/chapters.rb +102 -0
  53. data/lib/hecks/cli/behaviors.rb +61 -0
  54. data/lib/hecks/cli/canonicalise.rb +25 -0
  55. data/lib/hecks/cli/check_engine_agreement.rb +32 -0
  56. data/lib/hecks/cli/check_era.rb +88 -0
  57. data/lib/hecks/cli/console.rb +66 -0
  58. data/lib/hecks/cli/deploy_template_diff.rb +40 -0
  59. data/lib/hecks/cli/doc_coverage.rb +32 -0
  60. data/lib/hecks/cli/document.rb +2 -2
  61. data/lib/hecks/cli/expression_projection.rb +41 -0
  62. data/lib/hecks/cli/follow.rb +88 -0
  63. data/lib/hecks/cli/history.rb +44 -0
  64. data/lib/hecks/cli/ir.rb +1 -1
  65. data/lib/hecks/cli/mcp.rb +3 -3
  66. data/lib/hecks/cli/model_check.rb +7 -4
  67. data/lib/hecks/cli/pattern_cases.rb +47 -0
  68. data/lib/hecks/cli/present.rb +39 -0
  69. data/lib/hecks/cli/project_cli.rb +224 -32
  70. data/lib/hecks/cli/project_diagrams.rb +4 -3
  71. data/lib/hecks/cli/project_glossary.rb +44 -0
  72. data/lib/hecks/cli/reference.rb +46 -0
  73. data/lib/hecks/cli/refresh_projections.rb +44 -0
  74. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
  75. data/lib/hecks/cli/release.rb +70 -0
  76. data/lib/hecks/cli/release_gem.rb +59 -0
  77. data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
  78. data/lib/hecks/cli/rspec_shard_files.rb +66 -0
  79. data/lib/hecks/cli/run.rb +3 -3
  80. data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
  81. data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
  82. data/lib/hecks/cli/shape.rb +70 -0
  83. data/lib/hecks/cli/smoke_http.rb +230 -0
  84. data/lib/hecks/cli/smoke_test.rb +5 -3
  85. data/lib/hecks/cli/spec_example.rb +27 -0
  86. data/lib/hecks/cli/statements.rb +32 -0
  87. data/lib/hecks/cli/stores.rb +4 -5
  88. data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
  89. data/lib/hecks/cli.rb +201 -52
  90. data/lib/hecks/codemod.rb +324 -0
  91. data/lib/hecks/corpus.rb +423 -0
  92. data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
  93. data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
  94. data/lib/hecks/deploy/oidc.json +82 -2
  95. data/lib/hecks/doc/reference.rb +351 -0
  96. data/lib/hecks/doors/cli_door.rb +196 -0
  97. data/lib/hecks/doors/cli_runner.rb +297 -0
  98. data/lib/hecks/{facade → doors}/command_request.rb +1 -1
  99. data/lib/hecks/{facade → doors}/handle.rb +2 -2
  100. data/lib/hecks/{facade → doors}/json_door.rb +4 -4
  101. data/lib/hecks/doors/launcher_options.rb +129 -0
  102. data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
  103. data/lib/hecks/doors/mcp_door_scope.rb +97 -0
  104. data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
  105. data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
  106. data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
  107. data/lib/hecks/doors.rb +61 -0
  108. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
  109. data/lib/hecks/embryonaut_bluebook.rb +1 -1
  110. data/lib/hecks/engine_agreement.rb +156 -0
  111. data/lib/hecks/facade.rb +24 -37
  112. data/lib/hecks/forms/app.rb +6 -6
  113. data/lib/hecks/forms/banking_presentation.rb +50 -0
  114. data/lib/hecks/forms/field_shape.rb +2 -1
  115. data/lib/hecks/forms/port_argument.rb +1 -1
  116. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
  117. data/lib/hecks/fuzzing/combination_miner.rb +86 -0
  118. data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
  119. data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
  120. data/lib/hecks/fuzzing/differential.rb +156 -0
  121. data/lib/hecks/fuzzing/domain_generator.rb +615 -0
  122. data/lib/hecks/fuzzing/era_boundary.rb +71 -0
  123. data/lib/hecks/fuzzing/form_census.rb +136 -0
  124. data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
  125. data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
  126. data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
  127. data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
  128. data/lib/hecks/fuzzing/outside_world.rb +45 -0
  129. data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
  130. data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
  131. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
  132. data/lib/hecks/fuzzing/properties/guards.rb +190 -0
  133. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
  134. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
  135. data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
  136. data/lib/hecks/fuzzing/properties/querying.rb +175 -0
  137. data/lib/hecks/fuzzing/properties.rb +162 -0
  138. data/lib/hecks/fuzzing/qa_settings.rb +123 -0
  139. data/lib/hecks/fuzzing/replay.rb +505 -0
  140. data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
  141. data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
  142. data/lib/hecks/fuzzing/self_consistency.rb +339 -0
  143. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
  144. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
  145. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
  146. data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
  147. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
  148. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
  149. data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
  150. data/lib/hecks/fuzzing/shrinker.rb +166 -0
  151. data/lib/hecks/fuzzing/structural_skips.rb +33 -0
  152. data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
  153. data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
  154. data/lib/hecks/fuzzing/value_generator.rb +216 -0
  155. data/lib/hecks/fuzzing.rb +23 -0
  156. data/lib/hecks/grammar/evolve.rb +336 -0
  157. data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
  158. data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
  159. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
  160. data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
  161. data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
  162. data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
  163. data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
  164. data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
  165. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
  166. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
  167. data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
  168. data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
  169. data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
  170. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
  171. data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
  172. data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
  173. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
  174. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
  175. data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
  176. data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
  177. data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
  178. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
  179. data/lib/hecks/hecks/adapters/git.adapter +7 -0
  180. data/lib/hecks/hecks/adapters/git.rb +102 -0
  181. data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
  182. data/lib/hecks/hecks/adapters/host_http.rb +65 -0
  183. data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
  184. data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
  185. data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
  186. data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
  187. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
  188. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
  189. data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
  190. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
  191. data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
  192. data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
  193. data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
  194. data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
  195. data/lib/hecks/hecks/adapters/local_files.rb +46 -0
  196. data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
  197. data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
  198. data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
  199. data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
  200. data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
  201. data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
  202. data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
  203. data/lib/hecks/hecks/adapters/shell.adapter +7 -0
  204. data/lib/hecks/hecks/adapters/shell.rb +49 -0
  205. data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
  206. data/lib/hecks/hecks/adapters/terminal.rb +74 -0
  207. data/lib/hecks/hecks/codebase.behaviors +172 -0
  208. data/lib/hecks/hecks/codebase.bluebook +2453 -0
  209. data/lib/hecks/hecks/context_map.hecksagon +13 -0
  210. data/lib/hecks/hecks/custodian.behaviors +179 -0
  211. data/lib/hecks/hecks/custodian.bluebook +1847 -0
  212. data/lib/hecks/hecks/deploy.behaviors +74 -0
  213. data/lib/hecks/hecks/deploy.world +12 -0
  214. data/lib/hecks/hecks/environments/memory.world +10 -0
  215. data/lib/hecks/hecks/governance.world +7 -0
  216. data/lib/hecks/hecks/hecks.behaviors +62 -0
  217. data/lib/hecks/hecks/hecks.bluebook +124 -0
  218. data/lib/hecks/hecks/hecks.hecksagon +390 -0
  219. data/lib/hecks/hecks/hecks.world +42 -0
  220. data/lib/hecks/kernel_capabilities.rb +194 -0
  221. data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
  222. data/lib/hecks/language/bluebook/query.bluebook +7 -0
  223. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
  224. data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
  225. data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
  226. data/lib/hecks/mcp_stdio_guard.rb +2 -2
  227. data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
  228. data/lib/hecks/persistence_legacy_fixture.rb +113 -0
  229. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
  231. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
  237. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
  238. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
  239. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
  240. data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
  241. data/lib/hecks/ports/persistence/state_codec.rb +24 -5
  242. data/lib/hecks/projection_files.rb +285 -0
  243. data/lib/hecks/projections/bootstrap_table.rb +1 -1
  244. data/lib/hecks/projections/deploy/fargate.rb +5 -5
  245. data/lib/hecks/projections/deploy/lambda.rb +43 -32
  246. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  247. data/lib/hecks/projections/deploy/preview/template.rb +1 -1
  248. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
  249. data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
  250. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
  251. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
  252. data/lib/hecks/projections/deploy/shared.rb +1 -1
  253. data/lib/hecks/projections/diagrams.rb +3 -3
  254. data/lib/hecks/projections/model/deviations.rb +1 -1
  255. data/lib/hecks/projections/parser_table.rb +4 -4
  256. data/lib/hecks/projections/reference.rb +2 -3
  257. data/lib/hecks/projections/rust_vocabulary.rb +1 -1
  258. data/lib/hecks/projector/cli_projector.rb +106 -37
  259. data/lib/hecks/projector.rb +1 -1
  260. data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
  261. data/lib/hecks/quality_control/adapters/agent.rb +58 -0
  262. data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
  263. data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
  264. data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
  265. data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
  266. data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
  267. data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
  268. data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
  269. data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
  270. data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
  271. data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
  272. data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
  273. data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
  274. data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
  275. data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
  276. data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
  277. data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
  278. data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
  279. data/lib/hecks/quality_control/cli/child.rb +45 -0
  280. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
  281. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
  282. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
  283. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
  284. data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
  285. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
  286. data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
  287. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
  288. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
  289. data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
  290. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
  291. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
  292. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
  293. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
  294. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
  295. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
  296. data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
  297. data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
  298. data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
  299. data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
  300. data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
  301. data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
  302. data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
  303. data/lib/hecks/query_ir.rb +346 -0
  304. data/lib/hecks/query_ir_mcp.rb +198 -0
  305. data/lib/hecks/release/runner/ci_publisher.rb +2 -2
  306. data/lib/hecks/release/runner/clean_tree.rb +42 -0
  307. data/lib/hecks/release/runner/console.rb +6 -0
  308. data/lib/hecks/release/runner/gem_publisher.rb +14 -7
  309. data/lib/hecks/release/runner/npm_publisher.rb +6 -36
  310. data/lib/hecks/release/runner/preflight.rb +26 -2
  311. data/lib/hecks/release/runner/published.rb +8 -26
  312. data/lib/hecks/release/runner/tagger.rb +30 -15
  313. data/lib/hecks/release/runner.rb +57 -6
  314. data/lib/hecks/router/namespace_installer.rb +3 -3
  315. data/lib/hecks/runtime/adapter_lookup.rb +124 -0
  316. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
  317. data/lib/hecks/runtime/dependency_planning.rb +20 -2
  318. data/lib/hecks/runtime/dispatcher.rb +53 -3
  319. data/lib/hecks/runtime/interpreting.rb +3 -0
  320. data/lib/hecks/runtime/loader.rb +65 -12
  321. data/lib/hecks/runtime/outbox.rb +1 -1
  322. data/lib/hecks/runtime/policy_interpreter.rb +5 -2
  323. data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
  324. data/lib/hecks/runtime/query_interpreter.rb +77 -0
  325. data/lib/hecks/runtime/reaction_outcome.rb +89 -0
  326. data/lib/hecks/runtime/refusal_wording.rb +23 -1
  327. data/lib/hecks/runtime/registry/verification.rb +163 -4
  328. data/lib/hecks/runtime/registry.rb +57 -3
  329. data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
  330. data/lib/hecks/runtime/value/coercion.rb +23 -135
  331. data/lib/hecks/runtime/value/field_checks.rb +172 -0
  332. data/lib/hecks/runtime/value.rb +4 -1
  333. data/lib/hecks/runtime.rb +20 -6
  334. data/lib/hecks/rust_build/conformance.rb +158 -0
  335. data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
  336. data/lib/hecks/rust_build/coverage.rb +218 -0
  337. data/lib/hecks/rust_build/native_build.rb +124 -0
  338. data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
  339. data/lib/hecks/rust_build/project_rust.rb +332 -0
  340. data/lib/hecks/rust_build/wasm.rb +105 -0
  341. data/lib/hecks/rust_build/wasm_browser.rb +113 -0
  342. data/lib/hecks/rust_build.rb +144 -0
  343. data/lib/hecks/storehouse.rb +16 -16
  344. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
  345. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
  346. data/lib/hecks/three_zero/forms.yml +127 -0
  347. data/lib/hecks/three_zero.rb +12 -0
  348. data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
  349. data/lib/hecks/tools/comment_style.rb +1070 -0
  350. data/lib/hecks/tools/corpus_report.rb +87 -0
  351. data/lib/hecks/tools/deploy_recipe.rb +255 -0
  352. data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
  353. data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
  354. data/lib/hecks/tools/era_projection_backfill.rb +94 -0
  355. data/lib/hecks/tools/era_reattest.rb +146 -0
  356. data/lib/hecks/tools/evolve_run.rb +189 -0
  357. data/lib/hecks/tools/fuzz_sweep.rb +373 -0
  358. data/lib/hecks/tools/heki_compaction.rb +100 -0
  359. data/lib/hecks/tools/hoist_local_givens.rb +246 -0
  360. data/lib/hecks/tools/journal_compaction.rb +89 -0
  361. data/lib/hecks/tools/oidc_manifests.rb +70 -0
  362. data/lib/hecks/tools/query_ir_run.rb +81 -0
  363. data/lib/hecks/tools/regeneration_run.rb +166 -0
  364. data/lib/hecks/tools/rust_comment_style.rb +642 -0
  365. data/lib/hecks/tools/sequence_script.rb +62 -0
  366. data/lib/hecks/tools/tail_merge.rb +117 -0
  367. data/lib/hecks/tools/tenant_provisioning.rb +142 -0
  368. data/lib/hecks/tools/translation_audit.rb +208 -0
  369. data/lib/hecks/tools/translation_scaffold.rb +131 -0
  370. data/lib/hecks/tools.rb +86 -0
  371. data/lib/hecks/version.rb +1 -1
  372. data/lib/hecks.rb +32 -15
  373. data/qa/settings.yml +66 -0
  374. data/rust/.cargo/config.toml +6 -0
  375. data/rust/.gitignore +2 -0
  376. data/rust/Cargo.lock +54 -0
  377. data/rust/Cargo.toml +61 -0
  378. data/rust/build/.cargo/config.toml +3 -0
  379. data/rust/build/.gitignore +1 -0
  380. data/rust/build/Cargo.lock +7 -0
  381. data/rust/build/Cargo.toml +19 -0
  382. data/rust/build/src/build_artifact.rs +47 -0
  383. data/rust/build/src/cargo_sync.rs +248 -0
  384. data/rust/build/src/json.rs +421 -0
  385. data/rust/build/src/lineage_pass.rs +285 -0
  386. data/rust/build/src/main.rs +51 -0
  387. data/rust/build/src/optional_pass.rs +150 -0
  388. data/rust/build/src/pipeline.rs +264 -0
  389. data/rust/build/src/reserved_names.rs +76 -0
  390. data/rust/build/src/resolve.rs +124 -0
  391. data/rust/build/src/root.rs +25 -0
  392. data/rust/build/src/sidecars.rs +73 -0
  393. data/rust/build/src/subprocess.rs +51 -0
  394. data/rust/build/src/tmp.rs +31 -0
  395. data/rust/codegen/.cargo/config.toml +3 -0
  396. data/rust/codegen/.gitignore +1 -0
  397. data/rust/codegen/Cargo.lock +7 -0
  398. data/rust/codegen/Cargo.toml +20 -0
  399. data/rust/codegen/src/attr.rs +32 -0
  400. data/rust/codegen/src/bridging.rs +481 -0
  401. data/rust/codegen/src/commands.rs +1226 -0
  402. data/rust/codegen/src/constraints.rs +109 -0
  403. data/rust/codegen/src/dependency_planning.rs +209 -0
  404. data/rust/codegen/src/domain_generator.rs +1288 -0
  405. data/rust/codegen/src/exemplar.rs +230 -0
  406. data/rust/codegen/src/expr_emitter.rs +87 -0
  407. data/rust/codegen/src/fielded.rs +192 -0
  408. data/rust/codegen/src/hecks_naming.rs +33 -0
  409. data/rust/codegen/src/json.rs +335 -0
  410. data/rust/codegen/src/json_codec.rs +697 -0
  411. data/rust/codegen/src/literal.rs +207 -0
  412. data/rust/codegen/src/main.rs +290 -0
  413. data/rust/codegen/src/manifest.rs +227 -0
  414. data/rust/codegen/src/mutations.rs +784 -0
  415. data/rust/codegen/src/naming.rs +286 -0
  416. data/rust/codegen/src/ports.rs +208 -0
  417. data/rust/codegen/src/prelude.rs +116 -0
  418. data/rust/codegen/src/queries.rs +786 -0
  419. data/rust/codegen/src/reactions.rs +532 -0
  420. data/rust/codegen/src/read_models.rs +697 -0
  421. data/rust/codegen/src/reference_specs.rs +40 -0
  422. data/rust/codegen/src/registry.rs +966 -0
  423. data/rust/codegen/src/reserved_names.rs +76 -0
  424. data/rust/codegen/src/shared.rs +93 -0
  425. data/rust/codegen/src/skip_reason.rs +25 -0
  426. data/rust/codegen/src/types.rs +362 -0
  427. data/rust/host/.cargo/config.toml +3 -0
  428. data/rust/host/.gitignore +1 -0
  429. data/rust/host/Cargo.lock +4297 -0
  430. data/rust/host/Cargo.toml +61 -0
  431. data/rust/host/HECKS_RELEASE +1 -0
  432. data/rust/host/rds-ca-bundle.pem +2736 -0
  433. data/rust/host/src/api.rs +1596 -0
  434. data/rust/host/src/approval.rs +652 -0
  435. data/rust/host/src/auth.rs +1287 -0
  436. data/rust/host/src/bin/expr_harness.rs +46 -0
  437. data/rust/host/src/bin/lineage_harness.rs +119 -0
  438. data/rust/host/src/bin/mint_harness.rs +135 -0
  439. data/rust/host/src/checkout.rs +265 -0
  440. data/rust/host/src/dispatch.rs +1443 -0
  441. data/rust/host/src/expr_json.rs +1117 -0
  442. data/rust/host/src/field_hints.rs +52 -0
  443. data/rust/host/src/ir.rs +733 -0
  444. data/rust/host/src/journal.rs +874 -0
  445. data/rust/host/src/lambda_client.rs +378 -0
  446. data/rust/host/src/log.rs +147 -0
  447. data/rust/host/src/main.rs +474 -0
  448. data/rust/host/src/mint.rs +1865 -0
  449. data/rust/host/src/payments/keystore.rs +330 -0
  450. data/rust/host/src/payments/tests.rs +1400 -0
  451. data/rust/host/src/payments.rs +767 -0
  452. data/rust/host/src/presentation.rs +698 -0
  453. data/rust/host/src/presentation_write.rs +1431 -0
  454. data/rust/host/src/query_step.rs +149 -0
  455. data/rust/host/src/rate_limit.rs +904 -0
  456. data/rust/host/src/reference_transform.rs +241 -0
  457. data/rust/host/src/reference_validate.rs +338 -0
  458. data/rust/host/src/resend.rs +296 -0
  459. data/rust/host/src/secrets.rs +106 -0
  460. data/rust/host/src/server.rs +663 -0
  461. data/rust/host/src/storage_shape.rs +175 -0
  462. data/rust/host/src/test_pg.rs +40 -0
  463. data/rust/host/src/ui_schema.rs +1351 -0
  464. data/rust/host/src/wasm_runner.rs +191 -0
  465. data/rust/host/src/web/newsletter/cooldown.rs +126 -0
  466. data/rust/host/src/web/newsletter/tests.rs +104 -0
  467. data/rust/host/src/web/newsletter.rs +310 -0
  468. data/rust/host/src/web/newsletter_send.rs +357 -0
  469. data/rust/host/src/web/registrations.rs +1505 -0
  470. data/rust/host/src/web.rs +2937 -0
  471. data/rust/lsp/.gitignore +1 -0
  472. data/rust/lsp/Cargo.lock +7 -0
  473. data/rust/lsp/Cargo.toml +16 -0
  474. data/rust/lsp/README.md +133 -0
  475. data/rust/lsp/src/diagnostics.rs +165 -0
  476. data/rust/lsp/src/json.rs +385 -0
  477. data/rust/lsp/src/main.rs +399 -0
  478. data/rust/lsp/src/outline.rs +177 -0
  479. data/rust/lsp/src/rpc.rs +67 -0
  480. data/rust/parser/.cargo/config.toml +5 -0
  481. data/rust/parser/.gitignore +1 -0
  482. data/rust/parser/Cargo.lock +7 -0
  483. data/rust/parser/Cargo.toml +21 -0
  484. data/rust/parser/src/build/closed_sets.rs +27 -0
  485. data/rust/parser/src/build/identity.rs +276 -0
  486. data/rust/parser/src/build/mod.rs +12 -0
  487. data/rust/parser/src/build/naming.rs +141 -0
  488. data/rust/parser/src/build/pattern_subset.rs +161 -0
  489. data/rust/parser/src/build/query_derive.rs +72 -0
  490. data/rust/parser/src/build/query_inference.rs +269 -0
  491. data/rust/parser/src/build/query_options.rs +100 -0
  492. data/rust/parser/src/build/read_model.rs +94 -0
  493. data/rust/parser/src/build/references.rs +32 -0
  494. data/rust/parser/src/canonical.rs +114 -0
  495. data/rust/parser/src/diag.rs +55 -0
  496. data/rust/parser/src/emit.rs +953 -0
  497. data/rust/parser/src/expr/ast_json.rs +147 -0
  498. data/rust/parser/src/expr/evaluator.rs +81 -0
  499. data/rust/parser/src/expr/mod.rs +84 -0
  500. data/rust/parser/src/expr/resolver.rs +497 -0
  501. data/rust/parser/src/ir.rs +387 -0
  502. data/rust/parser/src/keywords.rs +585 -0
  503. data/rust/parser/src/lex.rs +662 -0
  504. data/rust/parser/src/main.rs +252 -0
  505. data/rust/parser/src/parse/aggregate.rs +612 -0
  506. data/rust/parser/src/parse/chapter.rs +517 -0
  507. data/rust/parser/src/parse/command.rs +637 -0
  508. data/rust/parser/src/parse/domain_port.rs +127 -0
  509. data/rust/parser/src/parse/entity.rs +527 -0
  510. data/rust/parser/src/parse/file.rs +67 -0
  511. data/rust/parser/src/parse/hecksagon.rs +231 -0
  512. data/rust/parser/src/parse/lifecycle.rs +162 -0
  513. data/rust/parser/src/parse/mod.rs +1262 -0
  514. data/rust/parser/src/parse/policy.rs +87 -0
  515. data/rust/parser/src/parse/process_manager.rs +305 -0
  516. data/rust/parser/src/parse/query.rs +100 -0
  517. data/rust/parser/src/parse/read_model.rs +214 -0
  518. data/rust/parser/src/parse/value_object.rs +250 -0
  519. data/rust/parser/src/ruby_value.rs +422 -0
  520. data/rust/project/bridging.rb +257 -0
  521. data/rust/project/commands.rb +877 -0
  522. data/rust/project/constraints.rb +86 -0
  523. data/rust/project/dependency_planning.rb +178 -0
  524. data/rust/project/domain_generator.rb +980 -0
  525. data/rust/project/exemplar.rb +148 -0
  526. data/rust/project/expr_emitter.rb +72 -0
  527. data/rust/project/fielded.rb +141 -0
  528. data/rust/project/json_codec.rb +641 -0
  529. data/rust/project/mutations.rb +721 -0
  530. data/rust/project/naming.rb +205 -0
  531. data/rust/project/ports.rb +65 -0
  532. data/rust/project/queries.rb +493 -0
  533. data/rust/project/reactions.rb +317 -0
  534. data/rust/project/read_models.rb +690 -0
  535. data/rust/project/reference_specs.rb +27 -0
  536. data/rust/project/registry.rb +396 -0
  537. data/rust/project/skip_reason.rb +29 -0
  538. data/rust/project/types.rb +225 -0
  539. data/rust/project/write_if_changed.rb +83 -0
  540. data/rust/project.rb +30 -0
  541. data/rust/project_rust_pipeline.rb +383 -0
  542. data/rust/src/exemplar/commands.rs +279 -0
  543. data/rust/src/exemplar/constraints.rs +18 -0
  544. data/rust/src/exemplar/fielded.rs +280 -0
  545. data/rust/src/exemplar/json.rs +277 -0
  546. data/rust/src/exemplar/mod.rs +17 -0
  547. data/rust/src/exemplar/mutations.rs +80 -0
  548. data/rust/src/exemplar/queries.rs +33 -0
  549. data/rust/src/exemplar/reactions.rs +88 -0
  550. data/rust/src/exemplar/read_models.rs +48 -0
  551. data/rust/src/exemplar/registry.rs +166 -0
  552. data/rust/src/exemplar/types.rs +44 -0
  553. data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
  554. data/rust/src/kernel/attribute_shapes/list.rs +18 -0
  555. data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
  556. data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
  557. data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
  558. data/rust/src/kernel/cli.rs +585 -0
  559. data/rust/src/kernel/dispatch.rs +1090 -0
  560. data/rust/src/kernel/expr.rs +458 -0
  561. data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
  562. data/rust/src/kernel/expression_operators/comparison.rs +48 -0
  563. data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
  564. data/rust/src/kernel/expression_operators/logical.rs +13 -0
  565. data/rust/src/kernel/expression_operators/membership.rs +18 -0
  566. data/rust/src/kernel/expression_operators/mod.rs +68 -0
  567. data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
  568. data/rust/src/kernel/expression_operators/positional.rs +126 -0
  569. data/rust/src/kernel/expression_operators/presence.rs +101 -0
  570. data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
  571. data/rust/src/kernel/expression_operators/sized.rs +35 -0
  572. data/rust/src/kernel/expression_operators/text.rs +79 -0
  573. data/rust/src/kernel/expression_operators/to_string.rs +18 -0
  574. data/rust/src/kernel/json.rs +779 -0
  575. data/rust/src/kernel/mod.rs +133 -0
  576. data/rust/src/kernel/named_query.rs +219 -0
  577. data/rust/src/kernel/orchestrate.rs +1810 -0
  578. data/rust/src/kernel/pattern.rs +307 -0
  579. data/rust/src/kernel/query_comparators.rs +206 -0
  580. data/rust/src/kernel/query_ordering.rs +103 -0
  581. data/rust/src/kernel/read_model.rs +914 -0
  582. data/rust/src/kernel/reference_lookup.rs +149 -0
  583. data/rust/src/kernel/repository.rs +468 -0
  584. data/rust/src/kernel/routing.rs +612 -0
  585. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
  586. data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
  587. data/rust/src/kernel/vocab/field_hint.rs +70 -0
  588. data/rust/src/kernel/vocab/mod.rs +20 -0
  589. data/rust/src/kernel/vocab/query_comparator.rs +65 -0
  590. data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
  591. data/rust/src/lib.rs +8 -0
  592. data/rust/src/main.rs +16 -0
  593. data/rust/web/.gitignore +1 -0
  594. data/rust/web/Cargo.lock +119 -0
  595. data/rust/web/Cargo.toml +18 -0
  596. data/rust/web/src/lib.rs +8 -0
  597. metadata +477 -12
  598. data/lib/hecks/facade/cli_door.rb +0 -96
  599. data/lib/hecks/facade/cli_runner.rb +0 -127
  600. data/lib/hecks/mcp_door_scope.rb +0 -95
@@ -0,0 +1,652 @@
1
+ //! Approval gate for migration edges with compute or rekey rules; ports `minter.rb`'s check.
2
+ //! `edge_digest` must match Ruby's `ApprovalDigest.edge_digest` byte for byte.
3
+ //!
4
+ //! Two approvals satisfy the gate. One is recorded in the journal and matches the edge's digest and
5
+ //! the journal's current tip. The other is committed beside the edge as
6
+ //! `translations/<edge>.approval`: it binds to the edge's digest alone, since a production journal
7
+ //! keeps writing between the commit and the deploy, and it must record a rehearsal that passed on a host of this release's `major.minor`.
8
+ //! A committed approval that applies is written into the journal, which stays the single history.
9
+
10
+ use crate::journal;
11
+ use serde_json::Value;
12
+ use sha2::{Digest, Sha256};
13
+ use tokio_postgres::GenericClient;
14
+
15
+ /// SHA256 of an edge's declared rules, identical to Ruby's `ApprovalDigest.edge_digest`.
16
+ /// The compiled-SQL fields of `ir.json` are ignored so a compiler change cannot void an approval.
17
+ pub fn edge_digest(edge: &Value) -> String {
18
+ let digest = Sha256::digest(canonical_edge(edge).as_bytes());
19
+ format!("{digest:x}")
20
+ }
21
+
22
+ /// Whether any aggregate on `edge` declares a compute or rekey rule.
23
+ pub fn requires_approval(edge: &Value) -> bool {
24
+ edge.get("aggregates").and_then(Value::as_array).into_iter().flatten().any(|aggregate| {
25
+ let non_empty = |key: &str| aggregate.get(key).and_then(Value::as_array).map(|list| !list.is_empty()).unwrap_or(false);
26
+ non_empty("computes") || non_empty("rekeys")
27
+ })
28
+ }
29
+
30
+ /// The Hecks release this host was built for, from `rust/host/HECKS_RELEASE`. The release step
31
+ /// keeps that file equal to `Hecks::VERSION`, and Ruby's `ApprovalFile::HOST_RELEASE` is that
32
+ /// constant, so both sides compare against one identity (the crate's own `0.1.0` is not it).
33
+ pub const HOST_RELEASE: &str = include_str!("../HECKS_RELEASE");
34
+
35
+ /// The `(major, minor)` a version names: `major.minor`, an optional patch, an optional
36
+ /// pre-release or build suffix, as Ruby's `ApprovalFile::HOST_LINE` reads it.
37
+ fn release_line(version: &str) -> Option<(u64, u64)> {
38
+ static SHAPE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
39
+ let shape = SHAPE.get_or_init(|| regex::Regex::new(r"^(\d+)\.(\d+)(?:\.\d+)?(?:[-+][0-9A-Za-z.+-]+)?$").expect("version pattern"));
40
+ let parts = shape.captures(version)?;
41
+ Some((parts[1].parse().ok()?, parts[2].parse().ok()?))
42
+ }
43
+
44
+ /// The refusal for a rehearsal that ran on another host line; Ruby's `ApprovalFile.host_refusal`
45
+ /// words it the same.
46
+ pub fn host_refusal(rehearsed_on: &str, host: &str) -> String {
47
+ let line = release_line(host).map(|(major, minor)| format!("{major}.{minor}")).unwrap_or_default();
48
+ format!(
49
+ "the rehearsal ran on Hecks {rehearsed_on}, but this host is Hecks {host}; a rehearsal counts only on a host of the same \
50
+ major.minor ({line}.x) — re-run the rehearsal on this host and approve again"
51
+ )
52
+ }
53
+
54
+ /// The rehearsal a committed approval records: the run a person made against real data.
55
+ #[derive(Debug, Clone, PartialEq, Eq)]
56
+ pub struct Rehearsal {
57
+ pub snapshot: String,
58
+ pub host_version: String,
59
+ pub result: String,
60
+ pub at: String,
61
+ }
62
+
63
+ /// An approval committed as `translations/<edge>.approval`, as `ir.json`'s `approvals` carries it.
64
+ #[derive(Debug, Clone, PartialEq, Eq)]
65
+ pub struct Committed {
66
+ pub edge: String,
67
+ pub edge_digest: String,
68
+ pub approved_by: String,
69
+ pub approved_at: String,
70
+ pub rehearsal: Option<Rehearsal>,
71
+ }
72
+
73
+ /// Whether `value` is an RFC 3339 time that exists on the calendar, the shape Ruby's
74
+ /// `ApprovalFile::TIMESTAMP` accepts: `2026-09-28T12:30:00Z`, with optional fraction and `+HH:MM`.
75
+ fn is_timestamp(value: &str) -> bool {
76
+ static SHAPE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
77
+ let shape = SHAPE.get_or_init(|| {
78
+ regex::Regex::new(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$").expect("timestamp pattern")
79
+ });
80
+ let Some(parts) = shape.captures(value) else { return false };
81
+ let part = |index: usize| parts[index].parse::<u32>().unwrap_or(u32::MAX);
82
+ let (year, month, day, hour, minute, second) = (part(1), part(2), part(3), part(4), part(5), part(6));
83
+ let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
84
+ let days = match month {
85
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
86
+ 4 | 6 | 9 | 11 => 30,
87
+ 2 if leap => 29,
88
+ 2 => 28,
89
+ _ => return false,
90
+ };
91
+ (1..=days).contains(&day) && hour < 24 && minute < 60 && second < 60
92
+ }
93
+
94
+ impl Committed {
95
+ /// Whether the approval names who approved it and when, the two things a reviewer signs.
96
+ pub fn attributed(&self) -> bool {
97
+ !self.approved_by.trim().is_empty() && is_timestamp(&self.approved_at)
98
+ }
99
+
100
+ /// Whether the rehearsal ran on a host compatible with `host`: equal `major.minor`. A patch
101
+ /// release does not change what a mint does, so a rehearsal survives it; a minor or major one
102
+ /// may, and a newer host's rehearsal proves nothing about an older one.
103
+ pub fn host_compatible(&self, host: &str) -> bool {
104
+ self.rehearsal.as_ref().is_some_and(|r| release_line(&r.host_version).is_some_and(|line| Some(line) == release_line(host)))
105
+ }
106
+
107
+ /// Whether the approval records a rehearsal that passed, every field named. Whether it ran on
108
+ /// a compatible host is `host_compatible`'s question.
109
+ pub fn rehearsed(&self) -> bool {
110
+ self.rehearsal.as_ref().is_some_and(|r| {
111
+ [&r.snapshot, &r.host_version, &r.result, &r.at].iter().all(|field| !field.trim().is_empty()) && r.result == "pass"
112
+ })
113
+ }
114
+ }
115
+
116
+ /// Reads the committed approvals out of `ir.json`; an entry with no digest approves nothing and is
117
+ /// left out.
118
+ pub fn committed(ir: &Value) -> Vec<Committed> {
119
+ let text = |value: &Value, key: &str| value.get(key).and_then(Value::as_str).unwrap_or("").to_string();
120
+ ir.get("approvals")
121
+ .and_then(Value::as_array)
122
+ .into_iter()
123
+ .flatten()
124
+ .filter(|approval| approval.get("edge_digest").and_then(Value::as_str).is_some_and(|digest| !digest.is_empty()))
125
+ .map(|approval| Committed {
126
+ edge: text(approval, "edge"),
127
+ edge_digest: text(approval, "edge_digest"),
128
+ approved_by: text(approval, "approved_by"),
129
+ approved_at: text(approval, "approved_at"),
130
+ rehearsal: approval.get("rehearsal").filter(|r| r.is_object()).map(|r| Rehearsal {
131
+ snapshot: text(r, "snapshot"),
132
+ host_version: text(r, "host_version"),
133
+ result: text(r, "result"),
134
+ at: text(r, "at"),
135
+ }),
136
+ })
137
+ .collect()
138
+ }
139
+
140
+ /// The committed approval that lets `edge` mint: its digest is the edge's, it names who approved it
141
+ /// and when, and a rehearsal that passed is recorded, since a compute or rekey is verified by
142
+ /// nothing else.
143
+ pub fn applicable<'a>(committed: &'a [Committed], edge: &Value) -> Option<&'a Committed> {
144
+ applicable_on(committed, edge, HOST_RELEASE.trim())
145
+ }
146
+
147
+ /// `applicable` for a host that reports `host` as its release.
148
+ pub fn applicable_on<'a>(committed: &'a [Committed], edge: &Value, host: &str) -> Option<&'a Committed> {
149
+ let digest = edge_digest(edge);
150
+ committed.iter().find(|approval| approval.edge_digest == digest && approval.attributed() && approval.rehearsed() && approval.host_compatible(host))
151
+ }
152
+
153
+ /// Why a committed approval that covers `edge` was still not applied, when the only thing wrong is
154
+ /// the host it was rehearsed on.
155
+ fn host_mismatch(committed: &[Committed], edge: &Value, host: &str) -> Option<String> {
156
+ let digest = edge_digest(edge);
157
+ committed
158
+ .iter()
159
+ .find(|approval| approval.edge_digest == digest && approval.attributed() && approval.rehearsed() && !approval.host_compatible(host))
160
+ .and_then(|approval| approval.rehearsal.as_ref())
161
+ .map(|rehearsal| host_refusal(&rehearsal.host_version, host))
162
+ }
163
+
164
+ /// Refuses to mint unless a compute/rekey edge has an approval: one from the journal whose digest
165
+ /// matches and whose reviewed ordinal is the current journal tip, or a committed one that applies
166
+ /// (which is then written into the journal). A no-op for edges with neither rule.
167
+ ///
168
+ /// # Errors
169
+ /// Fails when no approval applies: none is recorded, its digest differs, or the journal has moved
170
+ /// past a journal approval and no committed one covers the edge.
171
+ pub async fn check<C: GenericClient>(client: &C, domain: &str, edge: &Value, ordinal: i32, committed: &[Committed]) -> anyhow::Result<()> {
172
+ if !requires_approval(edge) {
173
+ return Ok(());
174
+ }
175
+
176
+ let from = edge.get("from").and_then(Value::as_str).unwrap_or("");
177
+ let to = edge.get("to").and_then(Value::as_str).unwrap_or("");
178
+ let digest = edge_digest(edge);
179
+
180
+ let approval = journal::approval_for(client, domain, from, to).await?;
181
+ let tip = journal::last_ordinal(client, domain).await?;
182
+ if let Some(approval) = approval.as_ref().filter(|approval| approval.edge_digest == digest) {
183
+ if approval.reviewed_ordinal == tip {
184
+ return Ok(());
185
+ }
186
+ }
187
+
188
+ if applicable(committed, edge).is_some() {
189
+ journal::record_approval(client, domain, from, to, &digest).await?;
190
+ return Ok(());
191
+ }
192
+
193
+ let approval = match approval {
194
+ Some(approval) if approval.edge_digest == digest => approval,
195
+ _ if host_mismatch(committed, edge, HOST_RELEASE.trim()).is_some() => anyhow::bail!(
196
+ "cannot mint era {ordinal} of {domain}: the committed approval does not apply — {}",
197
+ host_mismatch(committed, edge, HOST_RELEASE.trim()).unwrap_or_default()
198
+ ),
199
+ _ => anyhow::bail!(
200
+ "cannot mint era {ordinal} of {domain}: this edge carries a compute or rekey rule, and the audit's \
201
+ human-approved sample is its only verification — run hecks audit_translation with --approve, then boot again"
202
+ ),
203
+ };
204
+
205
+ anyhow::bail!(
206
+ "cannot mint era {ordinal} of {domain}: the journal advanced past the approved review (ordinal {} \
207
+ reviewed, {tip} now) — the samples a human approved no longer cover the data; re-run hecks audit_translation with --approve",
208
+ approval.reviewed_ordinal
209
+ )
210
+ }
211
+
212
+ fn canonical_edge(edge: &Value) -> String {
213
+ let domain = json_string(edge.get("domain").and_then(Value::as_str).unwrap_or(""));
214
+ let from = json_string(edge.get("from").and_then(Value::as_str).unwrap_or(""));
215
+ let to = json_string(edge.get("to").and_then(Value::as_str).unwrap_or(""));
216
+ let retired = join_array(edge.get("retired").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(json_string));
217
+ let aggregates = join_array(edge.get("aggregates").and_then(Value::as_array).into_iter().flatten().map(canonical_aggregate));
218
+ format!("{{\"domain\":{domain},\"from\":{from},\"to\":{to},\"retired\":{retired},\"aggregates\":{aggregates}}}")
219
+ }
220
+
221
+ fn canonical_aggregate(aggregate: &Value) -> String {
222
+ let name = json_string(aggregate.get("name").and_then(Value::as_str).unwrap_or(""));
223
+ let was = aggregate.get("was").and_then(Value::as_str).map(json_string).unwrap_or_else(|| "null".to_string());
224
+
225
+ // `renames` key order is declaration order and part of the digest (needs `preserve_order`).
226
+ let renames = join_object(
227
+ aggregate
228
+ .get("renames")
229
+ .and_then(Value::as_object)
230
+ .into_iter()
231
+ .flatten()
232
+ .map(|(key, value)| (key.clone(), json_string(value.as_str().unwrap_or("")))),
233
+ );
234
+
235
+ let moves = join_array(aggregate.get("moves").and_then(Value::as_array).into_iter().flatten().map(|m| {
236
+ let from = json_string(m.get("from").and_then(Value::as_str).unwrap_or(""));
237
+ let to = json_string(m.get("to").and_then(Value::as_str).unwrap_or(""));
238
+ format!("{{\"from\":{from},\"to\":{to}}}")
239
+ }));
240
+
241
+ let converts = join_array(aggregate.get("converts").and_then(Value::as_array).into_iter().flatten().map(|c| {
242
+ let from = json_string(c.get("from").and_then(Value::as_str).unwrap_or(""));
243
+ let to = json_string(c.get("to").and_then(Value::as_str).unwrap_or(""));
244
+ let values = join_array(c.get("values").and_then(Value::as_array).into_iter().flatten().map(|pair| {
245
+ let pair_items = pair.as_array().map(|p| p.iter().map(raw_json).collect::<Vec<_>>().join(",")).unwrap_or_default();
246
+ format!("[{pair_items}]")
247
+ }));
248
+ format!("{{\"from\":{from},\"to\":{to},\"values\":{values}}}")
249
+ }));
250
+
251
+ let drops = join_array(aggregate.get("drops").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(json_string));
252
+
253
+ let retypes = join_array(aggregate.get("retypes").and_then(Value::as_array).into_iter().flatten().map(|r| {
254
+ let from = json_string(r.get("from").and_then(Value::as_str).unwrap_or(""));
255
+ let to = json_string(r.get("to").and_then(Value::as_str).unwrap_or(""));
256
+ format!("{{\"from\":{from},\"to\":{to}}}")
257
+ }));
258
+
259
+ let computes = join_array(aggregate.get("computes").and_then(Value::as_array).into_iter().flatten().map(|c| {
260
+ let from = json_string(c.get("from").and_then(Value::as_str).unwrap_or(""));
261
+ let to = json_string(c.get("to").and_then(Value::as_str).unwrap_or(""));
262
+ let sql = json_string(c.get("sql").and_then(Value::as_str).unwrap_or(""));
263
+ format!("{{\"from\":{from},\"to\":{to},\"sql\":{sql}}}")
264
+ }));
265
+
266
+ let rekeys = join_array(aggregate.get("rekeys").and_then(Value::as_array).into_iter().flatten().map(|r| {
267
+ let sql = json_string(r.get("sql").and_then(Value::as_str).unwrap_or(""));
268
+ format!("{{\"sql\":{sql}}}")
269
+ }));
270
+
271
+ let backfills = join_array(aggregate.get("backfills").and_then(Value::as_array).into_iter().flatten().map(|b| {
272
+ let name = json_string(b.get("name").and_then(Value::as_str).unwrap_or(""));
273
+ let default = b.get("default").map(raw_json).unwrap_or_else(|| "null".to_string());
274
+ format!("{{\"name\":{name},\"default\":{default}}}")
275
+ }));
276
+
277
+ format!(
278
+ "{{\"name\":{name},\"was\":{was},\"renames\":{renames},\"moves\":{moves},\"converts\":{converts},\
279
+ \"drops\":{drops},\"retypes\":{retypes},\"computes\":{computes},\"rekeys\":{rekeys},\"backfills\":{backfills}}}"
280
+ )
281
+ }
282
+
283
+ fn join_array<I: Iterator<Item = String>>(items: I) -> String {
284
+ format!("[{}]", items.collect::<Vec<_>>().join(","))
285
+ }
286
+
287
+ fn join_object<I: Iterator<Item = (String, String)>>(pairs: I) -> String {
288
+ format!("{{{}}}", pairs.map(|(k, v)| format!("{}:{v}", json_string(&k))).collect::<Vec<_>>().join(","))
289
+ }
290
+
291
+ fn json_string(s: &str) -> String {
292
+ serde_json::to_string(s).expect("a plain &str always serializes")
293
+ }
294
+
295
+ // A backfill `default` is untyped JSON; serde_json's compact form of a `Value` is safe here.
296
+ fn raw_json(value: &Value) -> String {
297
+ serde_json::to_string(value).expect("a parsed Value always re-serializes")
298
+ }
299
+
300
+ #[cfg(test)]
301
+ mod tests {
302
+ use super::*;
303
+
304
+ // Pins Ruby's literal output for a real compute+rekey edge; the tests below only prove Rust
305
+ // agrees with itself.
306
+ #[test]
307
+ fn edge_digest_matches_ruby_s_own_output_for_a_real_compute_and_rekey_edge() {
308
+ let edge = serde_json::json!({
309
+ "domain": "LedgerCompute", "from": "aaaaaa", "to": "bbbbbb", "retired": [],
310
+ "aggregates": [{
311
+ "name": "Account", "was": null, "renames": {}, "moves": [], "converts": [], "drops": [], "retypes": [],
312
+ "computes": [{"from": "score", "to": "doubled", "sql": "jsonb_build_object('value', (score::jsonb->>'value')::int * 2)"}],
313
+ "rekeys": [{"sql": "state->>'kind'"}],
314
+ "backfills": [],
315
+ "compiled_state_expression": "(SELECT CASE WHEN __s ? 'score' THEN hecks_tr_insert(__s - 'score', ARRAY['doubled']::text[], to_jsonb((jsonb_build_object('value', (score::jsonb->>'value')::int * 2))), 'compute score to: doubled') ELSE __s END FROM (SELECT (state) AS __s) __outer, LATERAL (SELECT (__s ->> 'score') AS \"score\") __fields)",
316
+ "compiled_id_expression": "(SELECT (state->>'kind') FROM (SELECT (state) AS __s) __outer)"
317
+ }]
318
+ });
319
+
320
+ assert_eq!(edge_digest(&edge), "3803f00c11d5c613d2abb4f289a8a668e5885d2f36681134e509ed98408d520c");
321
+ }
322
+
323
+ // A backfill default or convert value is untyped JSON, and Ruby digests the text `JSON.generate`
324
+ // wrote for it: `1.0e+20`, `1.0e-05`, `1.0`, and integers past u64. The literals below are
325
+ // Ruby's own ir.json output for that edge and 0afbe95b... is Ruby's `edge_digest` of it.
326
+ #[test]
327
+ fn edge_digest_matches_ruby_for_floats_and_integers_beyond_u64_in_untyped_json() {
328
+ let ir: Value = serde_json::from_str(
329
+ r#"{"translations":[{"domain":"F","from":"aaaaaa","to":"bbbbbb","retired":[],"aggregates":[{"name":"Account","was":null,"renames":{},"moves":[],"converts":[{"from":"k","to":"k2","values":[[1.0e+20,12345678901234567890]]}],"drops":[],"retypes":[],"computes":[],"rekeys":[],"backfills":[{"name":"a","default":1.0e+20},{"name":"b","default":100000000000000000000},{"name":"c","default":1.0e-05},{"name":"d","default":1.0}]}]}]}"#,
330
+ )
331
+ .expect("Ruby's export is JSON");
332
+
333
+ assert_eq!(edge_digest(&ir["translations"][0]), "0afbe95b13fc8dbdab470acda48184eca6f898c4b736dd72b77a41977d7c0077");
334
+ }
335
+
336
+ #[test]
337
+ fn edge_digest_ignores_the_compiled_sql_fields() {
338
+ let edge = serde_json::json!({
339
+ "domain": "D", "from": "aaa", "to": "bbb", "retired": [],
340
+ "aggregates": [{
341
+ "name": "Widget", "was": null, "renames": {"cost": "amount"},
342
+ "moves": [], "converts": [], "drops": [], "retypes": [], "computes": [], "rekeys": [], "backfills": [],
343
+ "compiled_state_expression": "hecks_tr_rename(state, 'cost', 'amount')",
344
+ "compiled_id_expression": null
345
+ }]
346
+ });
347
+ let mut without_compiled = edge.clone();
348
+ without_compiled["aggregates"][0].as_object_mut().unwrap().remove("compiled_state_expression");
349
+ without_compiled["aggregates"][0].as_object_mut().unwrap().remove("compiled_id_expression");
350
+
351
+ assert_eq!(edge_digest(&edge), edge_digest(&without_compiled));
352
+ }
353
+
354
+ #[test]
355
+ fn edge_digest_is_sensitive_to_a_rekey_s_own_sql() {
356
+ let base = serde_json::json!({
357
+ "domain": "D", "from": "aaa", "to": "bbb", "retired": [],
358
+ "aggregates": [{
359
+ "name": "Widget", "was": null, "renames": {}, "moves": [], "converts": [], "drops": [], "retypes": [], "computes": [],
360
+ "rekeys": [{"sql": "state->>'a'"}], "backfills": []
361
+ }]
362
+ });
363
+ let mut different_rekey = base.clone();
364
+ different_rekey["aggregates"][0]["rekeys"][0]["sql"] = serde_json::json!("state->>'b'");
365
+
366
+ assert_ne!(edge_digest(&base), edge_digest(&different_rekey), "the fix this file exists for: a rekey's own SQL must be load-bearing in the digest");
367
+ }
368
+
369
+ #[test]
370
+ fn edge_digest_respects_renames_declaration_order_not_alphabetical() {
371
+ let ordered = serde_json::json!({
372
+ "domain": "D", "from": "aaa", "to": "bbb", "retired": [],
373
+ "aggregates": [{"name": "W", "was": null, "renames": {"zeta": "1", "alpha": "2"}, "moves": [], "converts": [], "drops": [], "retypes": [], "computes": [], "rekeys": [], "backfills": []}]
374
+ });
375
+ // Parsed from text, not json!{}, so a map that alphabetizes keys fails here instead of
376
+ // passing vacuously.
377
+ let reordered_text = r#"{"domain":"D","from":"aaa","to":"bbb","retired":[],"aggregates":[{"name":"W","was":null,"renames":{"alpha":"2","zeta":"1"},"moves":[],"converts":[],"drops":[],"retypes":[],"computes":[],"rekeys":[],"backfills":[]}]}"#;
378
+ let reordered: Value = serde_json::from_str(reordered_text).unwrap();
379
+
380
+ assert_ne!(edge_digest(&ordered), edge_digest(&reordered), "declaration order is meaningful data, not a cosmetic detail a digest may ignore");
381
+ }
382
+
383
+ // Against real Postgres: no approval refuses, a matching one at the tip passes, and a later
384
+ // journal write makes it stale again.
385
+ #[tokio::test]
386
+ async fn check_refuses_without_approval_succeeds_with_one_and_refuses_again_once_stale() {
387
+ let client = scratch_client("rust_host_approval_gate_test", "rust_host_approval_gate_owner").await;
388
+
389
+ let domain = "ApprovalGateTest";
390
+ crate::mint::ensure_base(&client, domain).await.expect("ensure_base");
391
+ // ensure_base forces RLS without a policy; advance_era adds the one the direct journal
392
+ // write below needs.
393
+ crate::mint::advance_era(&client, domain, 1).await.expect("advance_era");
394
+
395
+ let edge = serde_json::json!({
396
+ "domain": domain, "from": "aaaaaa", "to": "bbbbbb", "retired": [],
397
+ "aggregates": [{
398
+ "name": "Widget", "was": null, "renames": {}, "moves": [], "converts": [], "drops": [], "retypes": [], "computes": [],
399
+ "rekeys": [{"sql": "state->>'kind'"}], "backfills": []
400
+ }]
401
+ });
402
+
403
+ let refused = check(&client, domain, &edge, 2, &[]).await;
404
+ assert!(refused.is_err(), "an unapproved compute/rekey edge must refuse to mint");
405
+ assert!(format!("{:#}", refused.unwrap_err()).contains("hecks audit_translation"), "the refusal should name the tool that fixes it");
406
+
407
+ let digest = edge_digest(&edge);
408
+ client
409
+ .execute(
410
+ "INSERT INTO hecks_approvals (domain, from_label, to_label, edge_digest, reviewed_ordinal) VALUES ($1, $2, $3, $4, $5)",
411
+ &[&domain, &"aaaaaa", &"bbbbbb", &digest, &0i64],
412
+ )
413
+ .await
414
+ .expect("record approval");
415
+
416
+ let allowed = check(&client, domain, &edge, 2, &[]).await;
417
+ assert!(allowed.is_ok(), "a real, matching, unstale approval must let the mint proceed: {allowed:?}");
418
+
419
+ client
420
+ .execute(
421
+ "INSERT INTO hecks_journal_approval_gate_test (era, aggregate, aggregate_id, operation, state) VALUES (1, 'widget', 'w1', 'save', '{}'::jsonb)",
422
+ &[],
423
+ )
424
+ .await
425
+ .expect("write a journal row, advancing last_ordinal past the approval's own reviewed_ordinal");
426
+
427
+ let stale = check(&client, domain, &edge, 2, &[]).await;
428
+ assert!(stale.is_err(), "an approval reviewed against an EARLIER journal position must refuse once the journal has moved on");
429
+ assert!(format!("{:#}", stale.unwrap_err()).contains("advanced past"), "the refusal should name staleness specifically, not \"no approval\"");
430
+ }
431
+
432
+ // A scratch database owned by an ordinary role, as the host boots against a real one.
433
+ async fn scratch_client(db: &str, owner: &str) -> tokio_postgres::Client {
434
+ use tokio_postgres::NoTls;
435
+
436
+ let admin = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await.expect("connect to postgres as admin");
437
+ tokio::spawn(async move {
438
+ let _ = admin.1.await;
439
+ });
440
+ let _ = admin.0.batch_execute(&format!("DROP DATABASE IF EXISTS {db} WITH (FORCE)")).await;
441
+ admin.0.batch_execute(&format!("CREATE DATABASE {db}")).await.expect("create scratch db");
442
+ let _ = admin.0.batch_execute(&format!("DROP ROLE IF EXISTS {owner}")).await;
443
+ admin.0.batch_execute(&format!("CREATE ROLE {owner} {}", crate::test_pg::login_clause())).await.expect("create owner role");
444
+ admin.0.batch_execute(&format!("GRANT CONNECT ON DATABASE {db} TO {owner}")).await.expect("grant connect");
445
+
446
+ let grant = tokio_postgres::connect(&crate::test_pg::conninfo(&db), NoTls).await.expect("connect to scratch db as superuser");
447
+ tokio::spawn(async move {
448
+ let _ = grant.1.await;
449
+ });
450
+ grant.0.batch_execute(&format!("ALTER DATABASE {db} OWNER TO {owner}")).await.expect("make owner the db owner");
451
+ grant.0.batch_execute(&format!("GRANT USAGE, CREATE ON SCHEMA public TO {owner}")).await.expect("grant schema rights");
452
+
453
+ let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo_as(&db, &owner), NoTls).await.expect("connect as owner");
454
+ tokio::spawn(async move {
455
+ let _ = connection.await;
456
+ });
457
+ client
458
+ }
459
+
460
+ fn compute_rekey_edge(domain: &str) -> Value {
461
+ serde_json::json!({
462
+ "domain": domain, "from": "aaaaaa", "to": "bbbbbb", "retired": [],
463
+ "aggregates": [{
464
+ "name": "Widget", "was": null, "renames": {}, "moves": [], "converts": [], "drops": [], "retypes": [], "computes": [],
465
+ "rekeys": [{"sql": "state->>'kind'"}], "backfills": []
466
+ }]
467
+ })
468
+ }
469
+
470
+ fn committed_for(edge: &Value, rehearsal: Value) -> Committed {
471
+ let ir = serde_json::json!({ "approvals": [{
472
+ "edge": "aaaaaa-bbbbbb", "edge_digest": edge_digest(edge), "approved_by": "Ada <ada@example.com>",
473
+ "approved_at": "2026-09-28T12:30:00Z", "rehearsal": rehearsal
474
+ }]});
475
+ committed(&ir).remove(0)
476
+ }
477
+
478
+ fn passed() -> Value {
479
+ serde_json::json!({ "snapshot": "rds:ledger", "host_version": "3.0.0", "result": "pass", "at": "2026-09-28T12:00:00Z" })
480
+ }
481
+
482
+ // A rehearsal that passed on this very host.
483
+ fn passed_here() -> Value {
484
+ serde_json::json!({ "snapshot": "rds:ledger", "host_version": HOST_RELEASE.trim(), "result": "pass", "at": "2026-09-28T12:00:00Z" })
485
+ }
486
+
487
+ #[test]
488
+ fn the_host_release_is_a_version_and_not_the_crates_own() {
489
+ assert!(release_line(HOST_RELEASE.trim()).is_some(), "HECKS_RELEASE must hold a version: {HOST_RELEASE:?}");
490
+ assert_ne!(HOST_RELEASE.trim(), env!("CARGO_PKG_VERSION"));
491
+ }
492
+
493
+ // Ruby's `ApprovalFile.host_compatible?` is equal `major.minor`: a patch differs freely, an
494
+ // older or newer minor or major does not, and a name that is not a version is refused.
495
+ #[test]
496
+ fn a_rehearsal_counts_only_on_the_same_major_minor() {
497
+ let edge = compute_rekey_edge("D");
498
+ let on = |version: &str| {
499
+ let rehearsal = serde_json::json!({ "snapshot": "s", "host_version": version, "result": "pass", "at": "2026-09-28T12:00:00Z" });
500
+ applicable_on(&[committed_for(&edge, rehearsal)], &edge, "3.0.4").is_some()
501
+ };
502
+ assert!(on("3.0.0") && on("3.0.4") && on("3.0.9") && on("3.0.1-rc.1") && on("3.0"));
503
+ assert!(!on("2.9.0") && !on("3.1.0") && !on("4.0.0") && !on("3.10.0") && !on("") && !on("three") && !on("3"));
504
+ }
505
+
506
+ #[test]
507
+ fn the_refusal_names_both_versions() {
508
+ assert_eq!(
509
+ host_refusal("2.9.0", "3.0.4"),
510
+ "the rehearsal ran on Hecks 2.9.0, but this host is Hecks 3.0.4; a rehearsal counts only on a host of the same \
511
+ major.minor (3.0.x) — re-run the rehearsal on this host and approve again"
512
+ );
513
+ }
514
+
515
+ async fn journal_approvals(client: &tokio_postgres::Client, domain: &str) -> i64 {
516
+ client.query_one("SELECT count(*) AS n FROM hecks_approvals WHERE domain = $1", &[&domain]).await.expect("count approvals").get("n")
517
+ }
518
+
519
+ // Reads the parity fixture the Ruby spec writes from Ruby's own export of a compute+rekey edge and
520
+ // the approval Ruby writes for it: Rust must reach the digest Ruby recorded, from the same JSON.
521
+ #[test]
522
+ fn a_committed_approval_ruby_wrote_applies_to_the_edge_ruby_exported() {
523
+ let ir: Value = serde_json::from_str(include_str!("../../../spec/fixtures/approval_parity/ir.json")).expect("parity fixture is JSON");
524
+ let edge = &ir["translations"][0];
525
+ let approvals = committed(&ir);
526
+
527
+ assert_eq!(approvals.len(), 1);
528
+ assert_eq!(approvals[0].edge_digest, edge_digest(edge), "Ruby's approval digest and Rust's digest of Ruby's export must agree");
529
+ assert_eq!(approvals[0].edge, "aaaaaa-bbbbbb");
530
+ assert_eq!(approvals[0].approved_by, "Ada <ada@example.com>");
531
+ assert!(approvals[0].rehearsed());
532
+ assert_eq!(applicable_on(&approvals, edge, "3.0.7"), Some(&approvals[0]));
533
+ assert!(applicable_on(&approvals, edge, "3.1.0").is_none());
534
+ }
535
+
536
+ #[test]
537
+ fn a_committed_approval_applies_only_with_a_rehearsal_that_passed() {
538
+ let edge = compute_rekey_edge("D");
539
+ for rehearsal in [
540
+ Value::Null,
541
+ serde_json::json!({}),
542
+ serde_json::json!({ "snapshot": "s", "host_version": "3.0.0", "result": "fail", "at": "2026-09-28T12:00:00Z" }),
543
+ serde_json::json!({ "snapshot": " ", "host_version": "3.0.0", "result": "pass", "at": "2026-09-28T12:00:00Z" }),
544
+ ] {
545
+ let approval = committed_for(&edge, rehearsal);
546
+ assert!(applicable_on(&[approval], &edge, "3.0.0").is_none());
547
+ }
548
+ assert!(applicable_on(&[committed_for(&edge, passed())], &edge, "3.0.0").is_some());
549
+ }
550
+
551
+ // Ruby's `ApprovalFile` refuses the same: a non-String rehearsal field, a blank approver, an
552
+ // approved_at that is not a time on the calendar.
553
+ #[test]
554
+ fn a_committed_approval_needs_a_string_rehearsal_and_a_named_approver_and_time() {
555
+ let edge = compute_rekey_edge("D");
556
+ for rehearsal in [
557
+ serde_json::json!({ "snapshot": 42, "host_version": "3.0.0", "result": "pass", "at": "2026-09-28T12:00:00Z" }),
558
+ serde_json::json!({ "snapshot": "s", "host_version": 3.0, "result": "pass", "at": "2026-09-28T12:00:00Z" }),
559
+ serde_json::json!({ "snapshot": "s", "host_version": "3.0.0", "result": "pass", "at": null }),
560
+ ] {
561
+ assert!(applicable_on(&[committed_for(&edge, rehearsal)], &edge, "3.0.0").is_none());
562
+ }
563
+
564
+ let mut approval = committed_for(&edge, passed());
565
+ assert!(applicable_on(std::slice::from_ref(&approval), &edge, "3.0.0").is_some());
566
+ for who in ["", " "] {
567
+ approval.approved_by = who.to_string();
568
+ assert!(applicable_on(std::slice::from_ref(&approval), &edge, "3.0.0").is_none(), "blank approver {who:?}");
569
+ }
570
+ approval.approved_by = "Ada".to_string();
571
+ for at in ["", "yesterday", "2026-13-01T00:00:00Z", "2026-02-30T00:00:00Z", "2025-02-29T00:00:00Z", "2026-09-28T25:00:00Z", "2026-09-28"] {
572
+ approval.approved_at = at.to_string();
573
+ assert!(applicable_on(std::slice::from_ref(&approval), &edge, "3.0.0").is_none(), "approved_at {at:?}");
574
+ }
575
+ for at in ["2026-09-28T12:30:00.5+02:00", "2024-02-29T00:00:00Z"] {
576
+ approval.approved_at = at.to_string();
577
+ assert!(applicable_on(std::slice::from_ref(&approval), &edge, "3.0.0").is_some(), "approved_at {at:?}");
578
+ }
579
+ }
580
+
581
+ #[test]
582
+ fn a_committed_approval_binds_to_the_edge_digest_not_the_journal_tip() {
583
+ let edge = compute_rekey_edge("D");
584
+ let approval = committed_for(&edge, passed());
585
+ let mut changed = edge.clone();
586
+ changed["aggregates"][0]["rekeys"][0]["sql"] = serde_json::json!("state->>'other'");
587
+
588
+ assert!(applicable_on(std::slice::from_ref(&approval), &edge, "3.0.0").is_some());
589
+ assert!(applicable_on(&[approval], &changed, "3.0.0").is_none());
590
+ }
591
+
592
+ #[test]
593
+ fn committed_reads_nothing_from_ir_without_approvals_and_skips_an_entry_with_no_digest() {
594
+ assert!(committed(&serde_json::json!({})).is_empty());
595
+ assert!(committed(&serde_json::json!({ "approvals": [{ "edge": "a-b" }] })).is_empty());
596
+ }
597
+
598
+ // Against real Postgres: a committed approval satisfies the gate with no journal approval, is
599
+ // written into the journal when it applies, keeps applying after the journal moves on, and a
600
+ // journal approval at the tip still works alone.
601
+ #[tokio::test]
602
+ async fn a_committed_approval_lets_the_mint_through_and_joins_the_journal() {
603
+ let client = scratch_client("rust_host_committed_approval_test", "rust_host_committed_approval_owner").await;
604
+ let domain = "CommittedApprovalTest";
605
+ crate::mint::ensure_base(&client, domain).await.expect("ensure_base");
606
+ crate::mint::advance_era(&client, domain, 1).await.expect("advance_era");
607
+ let edge = compute_rekey_edge(domain);
608
+
609
+ let refused = check(&client, domain, &edge, 2, &[]).await;
610
+ assert!(refused.is_err(), "no approval at all still refuses");
611
+ assert_eq!(journal_approvals(&client, domain).await, 0);
612
+
613
+ let without_rehearsal = committed_for(&edge, Value::Null);
614
+ assert!(check(&client, domain, &edge, 2, &[without_rehearsal]).await.is_err(), "a committed approval with no passed rehearsal is no approval");
615
+ assert_eq!(journal_approvals(&client, domain).await, 0);
616
+
617
+ let approval = committed_for(&edge, passed_here());
618
+ check(&client, domain, &edge, 2, std::slice::from_ref(&approval)).await.expect("a committed approval applies");
619
+ assert_eq!(journal_approvals(&client, domain).await, 1, "applying it writes it into the journal");
620
+ assert_eq!(journal::approval_for(&client, domain, "aaaaaa", "bbbbbb").await.unwrap().unwrap().edge_digest, edge_digest(&edge));
621
+
622
+ // The journal now holds the approval at the tip, so it satisfies the gate alone.
623
+ check(&client, domain, &edge, 2, &[]).await.expect("the journal's own approval, at the tip, still works");
624
+
625
+ client
626
+ .execute(
627
+ "INSERT INTO hecks_journal_committed_approval_test (era, aggregate, aggregate_id, operation, state) VALUES (1, 'widget', 'w1', 'save', '{}'::jsonb)",
628
+ &[],
629
+ )
630
+ .await
631
+ .expect("the journal moves on");
632
+ assert!(check(&client, domain, &edge, 2, &[]).await.is_err(), "the journal's approval is stale now");
633
+ check(&client, domain, &edge, 2, &[approval]).await.expect("the committed approval is not tied to the tip");
634
+ }
635
+
636
+ #[tokio::test]
637
+ async fn a_committed_approval_rehearsed_on_another_release_refuses_naming_both_versions() {
638
+ let client = scratch_client("rust_host_committed_approval_host_test", "rust_host_committed_approval_host_owner").await;
639
+ let domain = "CommittedApprovalHostTest";
640
+ crate::mint::ensure_base(&client, domain).await.expect("ensure_base");
641
+ crate::mint::advance_era(&client, domain, 1).await.expect("advance_era");
642
+ let edge = compute_rekey_edge(domain);
643
+ let stale = committed_for(
644
+ &edge,
645
+ serde_json::json!({ "snapshot": "s", "host_version": "1.0.0", "result": "pass", "at": "2026-09-28T12:00:00Z" }),
646
+ );
647
+
648
+ let message = format!("{:#}", check(&client, domain, &edge, 2, &[stale]).await.unwrap_err());
649
+ assert!(message.contains("Hecks 1.0.0") && message.contains(HOST_RELEASE.trim()), "{message}");
650
+ assert_eq!(journal_approvals(&client, domain).await, 0);
651
+ }
652
+ }