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,1865 @@
1
+ //! Mints Postgres eras, porting `lib/hecks/adapters/driven/postgres_era/
2
+ //! lineage/*.rb`'s SQL verbatim; this crate has no bluebook parser (ADR 0012).
3
+
4
+ use anyhow::Context;
5
+ use crate::journal::quote_ident;
6
+ use crate::reference_transform;
7
+ use crate::reference_validate;
8
+ use crate::storage_shape;
9
+ use serde_json::Value;
10
+ use sha2::{Digest, Sha256};
11
+ use tokio_postgres::GenericClient;
12
+
13
+ fn journal_table(domain: &str) -> String {
14
+ format!("hecks_journal_{}", storage_shape_snake(domain))
15
+ }
16
+
17
+ fn sequence(domain: &str) -> String {
18
+ format!("{}_ordinal", journal_table(domain))
19
+ }
20
+
21
+ fn partition(domain: &str, era: i32) -> String {
22
+ format!("{}_era_{era}", journal_table(domain))
23
+ }
24
+
25
+ // Domain-qualified: two domains whose aggregate names snake_case to the
26
+ // same storage_name would otherwise collide on the same relations (docs/decisions/0059).
27
+ fn head_snapshot(domain: &str, storage_name: &str, era: i32) -> String {
28
+ crate::journal::qualified_name(domain, &format!("{storage_name}_head_snapshot_{era}"))
29
+ }
30
+
31
+ fn matview(domain: &str, storage_name: &str, era: i32, label: &str) -> String {
32
+ crate::journal::qualified_name(domain, &format!("{storage_name}_lineage_{era}_{label}"))
33
+ }
34
+
35
+ fn head_view(domain: &str, storage_name: &str) -> String {
36
+ crate::journal::qualified_name(domain, &format!("{storage_name}_head"))
37
+ }
38
+
39
+ // `Naming.snake` — same pure syntactic transform `crate::journal::snake`
40
+ // already ports; reused directly rather than duplicated a second time.
41
+ fn storage_shape_snake(name: &str) -> String {
42
+ crate::journal::snake(name)
43
+ }
44
+
45
+ fn text_literal(text: &str) -> String {
46
+ format!("'{}'", text.replace('\'', "''"))
47
+ }
48
+
49
+ /// One aggregate's identity: its domain name and its storage (snake_case)
50
+ /// name, the pair `ir.json`'s `lineage.capable_aggregates` carries.
51
+ #[derive(Debug, Clone)]
52
+ pub struct Aggregate {
53
+ pub name: String,
54
+ pub storage_name: String,
55
+ }
56
+
57
+ /// One aggregate's compiled translation rule within an edge.
58
+ #[derive(Debug, Clone)]
59
+ pub struct EdgeAggregate {
60
+ pub name: String,
61
+ pub was: Option<String>,
62
+ pub has_compute_or_rekey: bool,
63
+ pub compiled_state_expression: String,
64
+ pub compiled_id_expression: Option<String>,
65
+ }
66
+
67
+ /// One translation edge between two era shapes.
68
+ #[derive(Debug, Clone)]
69
+ pub struct Edge {
70
+ pub from: String,
71
+ pub to: String,
72
+ pub aggregates: Vec<EdgeAggregate>,
73
+ }
74
+
75
+ impl Edge {
76
+ fn for_aggregate(&self, name: &str) -> Option<&EdgeAggregate> {
77
+ self.aggregates.iter().find(|a| a.name == name)
78
+ }
79
+ }
80
+
81
+ /// `ir.json`'s `translations` array, filtered to one domain and read back
82
+ /// generically — this crate links no codegen crate (ADR 0012).
83
+ pub fn parse_edges(ir: &Value, domain: &str) -> Vec<Edge> {
84
+ ir.get("translations")
85
+ .and_then(Value::as_array)
86
+ .map(|list| {
87
+ list.iter()
88
+ .filter(|edge| edge.get("domain").and_then(Value::as_str) == Some(domain))
89
+ .map(|edge| Edge {
90
+ from: edge.get("from").and_then(Value::as_str).unwrap_or_default().to_string(),
91
+ to: edge.get("to").and_then(Value::as_str).unwrap_or_default().to_string(),
92
+ aggregates: edge
93
+ .get("aggregates")
94
+ .and_then(Value::as_array)
95
+ .map(|aggs| {
96
+ aggs.iter()
97
+ .map(|agg| EdgeAggregate {
98
+ name: agg.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
99
+ was: agg.get("was").and_then(Value::as_str).map(str::to_string),
100
+ has_compute_or_rekey: !agg.get("computes").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true)
101
+ || !agg.get("rekeys").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true),
102
+ compiled_state_expression: agg
103
+ .get("compiled_state_expression")
104
+ .and_then(Value::as_str)
105
+ .unwrap_or("state")
106
+ .to_string(),
107
+ compiled_id_expression: agg.get("compiled_id_expression").and_then(Value::as_str).map(str::to_string),
108
+ })
109
+ .collect()
110
+ })
111
+ .unwrap_or_default(),
112
+ })
113
+ .collect()
114
+ })
115
+ .unwrap_or_default()
116
+ }
117
+
118
+ /// The edge chain from era 1's label to `to_label`, in mint order. Refuses
119
+ /// when some era's label has no edge leaving it toward the next.
120
+ pub fn edge_chain<'a>(edges: &'a [Edge], labels: &[String]) -> anyhow::Result<Vec<&'a Edge>> {
121
+ (0..labels.len() - 1)
122
+ .map(|index| {
123
+ edges
124
+ .iter()
125
+ .find(|edge| edge.from == labels[index] && edge.to == labels[index + 1])
126
+ .ok_or_else(|| {
127
+ anyhow::anyhow!(
128
+ "the edge chain is broken at era {} — no translation leads {} to {}; restore bluebook/translations/",
129
+ index + 1,
130
+ labels[index],
131
+ labels[index + 1]
132
+ )
133
+ })
134
+ })
135
+ .collect()
136
+ }
137
+
138
+ /// What `main()`'s boot gate should do, given the held eras and this
139
+ /// binary's shape label. Pure, so the four-way branch is testable without Postgres.
140
+ #[derive(Debug, Clone, PartialEq, Eq)]
141
+ pub enum BootDecision {
142
+ /// A held era already names this exact shape — boot at it. RLS
143
+ /// refuses a write if it's superseded before the transaction lands.
144
+ UseExisting { ordinal: i32 },
145
+ /// No held era at all — this domain is new to this database. Mint era 1.
146
+ HoldFirst,
147
+ /// The latest held era differs from this binary's shape — walk the
148
+ /// edge chain from it and mint the next ordinal.
149
+ Mint { ordinal: i32, from_ordinal: i32, from_label: String },
150
+ /// The latest held era has no label yet — this crate has no parser to
151
+ /// name it, so it refuses rather than guess whether it matches.
152
+ LatestUnnamed { ordinal: i32 },
153
+ }
154
+
155
+ impl BootDecision {
156
+ /// The outcome's name, as the boot log reports it.
157
+ pub fn name(&self) -> &'static str {
158
+ match self {
159
+ BootDecision::UseExisting { .. } => "use_existing",
160
+ BootDecision::HoldFirst => "hold_first",
161
+ BootDecision::Mint { .. } => "mint",
162
+ BootDecision::LatestUnnamed { .. } => "latest_unnamed",
163
+ }
164
+ }
165
+ }
166
+
167
+ /// `held` must already be in ordinal order (as `journal::held_eras` returns it).
168
+ pub fn decide_boot_action(held: &[crate::journal::HeldEra], my_label: &str) -> BootDecision {
169
+ if let Some(matching) = held.iter().find(|held_era| held_era.label.as_deref() == Some(my_label)) {
170
+ return BootDecision::UseExisting { ordinal: matching.ordinal };
171
+ }
172
+ if held.is_empty() {
173
+ return BootDecision::HoldFirst;
174
+ }
175
+ let latest = held.last().expect("checked non-empty above");
176
+ match &latest.label {
177
+ None => BootDecision::LatestUnnamed { ordinal: latest.ordinal },
178
+ Some(label) => BootDecision::Mint {
179
+ ordinal: latest.ordinal + 1,
180
+ from_ordinal: latest.ordinal,
181
+ from_label: label.clone(),
182
+ },
183
+ }
184
+ }
185
+
186
+ async fn provisioner<C: GenericClient>(client: &C, journal: &str) -> anyhow::Result<bool> {
187
+ let row = client
188
+ .query_opt(
189
+ "SELECT pg_get_userbyid(relowner) = current_user AS owned FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)",
190
+ &[&journal],
191
+ )
192
+ .await?;
193
+ Ok(match row {
194
+ None => true,
195
+ Some(row) => row.get::<_, bool>("owned"),
196
+ })
197
+ }
198
+
199
+ async fn partition_attached<C: GenericClient>(client: &C, partition_name: &str, journal: &str) -> anyhow::Result<bool> {
200
+ let row = client
201
+ .query_opt(
202
+ "SELECT 1 FROM pg_inherits i \
203
+ JOIN pg_class child ON child.oid = i.inhrelid \
204
+ JOIN pg_class parent ON parent.oid = i.inhparent \
205
+ WHERE child.relname = $1 AND parent.relname = $2 \
206
+ AND pg_table_is_visible(child.oid) AND pg_table_is_visible(parent.oid)",
207
+ &[&partition_name, &journal],
208
+ )
209
+ .await?;
210
+ Ok(row.is_some())
211
+ }
212
+
213
+ async fn ensure_partition<C: GenericClient>(client: &C, domain: &str, era: i32) -> anyhow::Result<()> {
214
+ let journal = journal_table(domain);
215
+ let part = partition(domain, era);
216
+ if partition_attached(client, &part, &journal).await? {
217
+ return Ok(());
218
+ }
219
+ client
220
+ .batch_execute(&format!("CREATE TABLE IF NOT EXISTS {} (LIKE {} INCLUDING DEFAULTS)", quote_ident(&part), quote_ident(&journal)))
221
+ .await?;
222
+ client
223
+ .batch_execute(&format!("ALTER TABLE {} ATTACH PARTITION {} FOR VALUES IN ({era})", quote_ident(&journal), quote_ident(&part)))
224
+ .await?;
225
+ Ok(())
226
+ }
227
+
228
+ async fn install_transforms<C: GenericClient>(client: &C) -> anyhow::Result<()> {
229
+ const FUNCTIONS: &[&str] = &[
230
+ r#"CREATE OR REPLACE FUNCTION hecks_tr_extract(state jsonb, path text[], OUT remaining jsonb, OUT value jsonb, OUT present boolean)
231
+ LANGUAGE plpgsql IMMUTABLE AS $fn$
232
+ DECLARE parent jsonb; leaf text;
233
+ BEGIN
234
+ remaining := state;
235
+ present := false;
236
+ leaf := path[array_upper(path, 1)];
237
+ IF array_length(path, 1) = 1 THEN
238
+ IF state ? leaf THEN
239
+ present := true;
240
+ value := state -> leaf;
241
+ remaining := state - leaf;
242
+ END IF;
243
+ RETURN;
244
+ END IF;
245
+ parent := state #> path[1:array_upper(path, 1) - 1];
246
+ IF jsonb_typeof(parent) = 'object' AND parent ? leaf THEN
247
+ present := true;
248
+ value := parent -> leaf;
249
+ parent := parent - leaf;
250
+ IF parent = '{}'::jsonb THEN
251
+ remaining := state - path[1];
252
+ ELSE
253
+ remaining := jsonb_set(state, path[1:array_upper(path, 1) - 1], parent);
254
+ END IF;
255
+ END IF;
256
+ END $fn$"#,
257
+ r#"CREATE OR REPLACE FUNCTION hecks_tr_insert(state jsonb, path text[], value jsonb, rule_label text) RETURNS jsonb
258
+ LANGUAGE plpgsql IMMUTABLE AS $fn$
259
+ BEGIN
260
+ IF array_length(path, 1) = 1 THEN
261
+ RETURN state || jsonb_build_object(path[1], value);
262
+ END IF;
263
+ IF state ? path[1] AND jsonb_typeof(state -> path[1]) <> 'object' THEN
264
+ RAISE EXCEPTION 'cannot %: % already holds %, not a value this can nest under — moving into it would discard that value silently. Rename or drop % first.',
265
+ rule_label, path[1], state -> path[1], path[1];
266
+ END IF;
267
+ IF state -> path[1] IS NULL THEN
268
+ state := state || jsonb_build_object(path[1], '{}'::jsonb);
269
+ END IF;
270
+ RETURN jsonb_set(state, path, value);
271
+ END $fn$"#,
272
+ r#"CREATE OR REPLACE FUNCTION hecks_tr_rename(state jsonb, old_name text, new_name text) RETURNS jsonb
273
+ LANGUAGE sql IMMUTABLE AS $fn$
274
+ SELECT CASE WHEN state ? old_name
275
+ THEN (state - old_name) || jsonb_build_object(new_name, state -> old_name)
276
+ ELSE state END
277
+ $fn$"#,
278
+ r#"CREATE OR REPLACE FUNCTION hecks_tr_move(state jsonb, from_path text[], to_path text[], rule_label text) RETURNS jsonb
279
+ LANGUAGE plpgsql IMMUTABLE AS $fn$
280
+ DECLARE extracted record;
281
+ BEGIN
282
+ SELECT * INTO extracted FROM hecks_tr_extract(state, from_path);
283
+ IF NOT extracted.present THEN RETURN state; END IF;
284
+ RETURN hecks_tr_insert(extracted.remaining, to_path, extracted.value, rule_label);
285
+ END $fn$"#,
286
+ r#"CREATE OR REPLACE FUNCTION hecks_tr_convert(state jsonb, from_path text[], to_path text[], pairs jsonb, from_label text, rule_label text) RETURNS jsonb
287
+ LANGUAGE plpgsql IMMUTABLE AS $fn$
288
+ DECLARE extracted record; pair jsonb;
289
+ BEGIN
290
+ SELECT * INTO extracted FROM hecks_tr_extract(state, from_path);
291
+ IF NOT extracted.present THEN RETURN state; END IF;
292
+ FOR pair IN SELECT * FROM jsonb_array_elements(pairs) LOOP
293
+ IF pair -> 0 = extracted.value THEN
294
+ RETURN hecks_tr_insert(extracted.remaining, to_path, pair -> 1, rule_label);
295
+ END IF;
296
+ END LOOP;
297
+ RAISE EXCEPTION 'cannot translate %: % has no mapping in its convert''s values: table. Add % => ... to cover it.',
298
+ from_label, extracted.value, extracted.value;
299
+ END $fn$"#,
300
+ r#"CREATE OR REPLACE FUNCTION hecks_tr_drop(state jsonb, path text[]) RETURNS jsonb
301
+ LANGUAGE plpgsql IMMUTABLE AS $fn$
302
+ DECLARE extracted record;
303
+ BEGIN
304
+ SELECT * INTO extracted FROM hecks_tr_extract(state, path);
305
+ RETURN extracted.remaining;
306
+ END $fn$"#,
307
+ ];
308
+ for sql in FUNCTIONS {
309
+ client.batch_execute(sql).await?;
310
+ }
311
+ Ok(())
312
+ }
313
+
314
+ /// Provisions a domain's tables and functions once, ever. Idempotent, and
315
+ /// gated by ownership since a non-owner role has no business running DDL.
316
+ pub async fn ensure_base<C: GenericClient>(client: &C, domain: &str) -> anyhow::Result<()> {
317
+ let journal = journal_table(domain);
318
+ if !provisioner(client, &journal).await? {
319
+ return Ok(());
320
+ }
321
+
322
+ client
323
+ .batch_execute(
324
+ "CREATE TABLE IF NOT EXISTS hecks_eras (\
325
+ domain text NOT NULL, ordinal int NOT NULL, hash text, label text, \
326
+ held_text text NOT NULL, watermark bigint, \
327
+ PRIMARY KEY (domain, ordinal))",
328
+ )
329
+ .await?;
330
+ client.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_digest text").await?;
331
+ client.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_projection jsonb").await?;
332
+ client.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS canon_form int").await?;
333
+ client
334
+ .batch_execute(
335
+ "CREATE TABLE IF NOT EXISTS hecks_era_texts (\
336
+ domain text NOT NULL, ordinal int NOT NULL, digest text NOT NULL, held_text text NOT NULL, \
337
+ archived_at timestamptz NOT NULL DEFAULT now(), \
338
+ PRIMARY KEY (domain, ordinal, digest))",
339
+ )
340
+ .await?;
341
+ client
342
+ .batch_execute(
343
+ "CREATE TABLE IF NOT EXISTS hecks_approvals (\
344
+ domain text NOT NULL, from_label text NOT NULL, to_label text NOT NULL, \
345
+ edge_digest text NOT NULL, reviewed_ordinal bigint NOT NULL, \
346
+ approved_at timestamptz NOT NULL DEFAULT now())",
347
+ )
348
+ .await?;
349
+ client.batch_execute("CREATE TABLE IF NOT EXISTS hecks_backfill_progress (\
350
+ target text PRIMARY KEY, cursor text, completed boolean NOT NULL DEFAULT false, \
351
+ updated_at timestamptz NOT NULL DEFAULT now())").await?;
352
+
353
+ let seq = sequence(domain);
354
+ client.batch_execute(&format!("CREATE SEQUENCE IF NOT EXISTS {}", quote_ident(&seq))).await?;
355
+ client
356
+ .batch_execute(&format!(
357
+ "CREATE TABLE IF NOT EXISTS {} (\
358
+ ordinal bigint NOT NULL DEFAULT nextval('{seq}'), era int NOT NULL, \
359
+ aggregate text NOT NULL, aggregate_id text NOT NULL, \
360
+ operation text NOT NULL DEFAULT 'save', state jsonb, mirrors jsonb) \
361
+ PARTITION BY LIST (era)",
362
+ quote_ident(&journal)
363
+ ))
364
+ .await?;
365
+
366
+ ensure_partition(client, domain, 1).await?;
367
+
368
+ // Revokes only once: `relacl IS NULL` is the one honest signal, since
369
+ // `has_table_privilege` reads false for both "never revoked" and "already revoked".
370
+ let relacl_null = client
371
+ .query_opt(
372
+ "SELECT relacl IS NULL FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)",
373
+ &[&journal],
374
+ )
375
+ .await?
376
+ .map(|row| row.get::<_, bool>(0))
377
+ .unwrap_or(true);
378
+ if relacl_null {
379
+ client.batch_execute(&format!("REVOKE UPDATE, DELETE ON {} FROM PUBLIC", quote_ident(&journal))).await?;
380
+ }
381
+
382
+ let current = client
383
+ .query_opt(
384
+ "SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)",
385
+ &[&journal],
386
+ )
387
+ .await?;
388
+ let (rowsecurity, forcerowsecurity) = current.map(|row| (row.get::<_, bool>("relrowsecurity"), row.get::<_, bool>("relforcerowsecurity"))).unwrap_or((false, false));
389
+ if !rowsecurity {
390
+ client.batch_execute(&format!("ALTER TABLE {} ENABLE ROW LEVEL SECURITY", quote_ident(&journal))).await?;
391
+ }
392
+ if !forcerowsecurity {
393
+ client.batch_execute(&format!("ALTER TABLE {} FORCE ROW LEVEL SECURITY", quote_ident(&journal))).await?;
394
+ }
395
+
396
+ install_transforms(client).await?;
397
+ Ok(())
398
+ }
399
+
400
+ const CHUNK_SIZE: i64 = 5_000;
401
+
402
+ async fn backfill_progress<C: GenericClient>(client: &C, target: &str) -> anyhow::Result<(Option<String>, bool)> {
403
+ let row = client.query_opt("SELECT cursor, completed FROM hecks_backfill_progress WHERE target = $1", &[&target]).await?;
404
+ Ok(match row {
405
+ None => (None, false),
406
+ Some(row) => (row.get("cursor"), row.get("completed")),
407
+ })
408
+ }
409
+
410
+ async fn upsert_backfill_progress<C: GenericClient>(client: &C, target: &str, cursor: Option<&str>, completed: bool) -> anyhow::Result<()> {
411
+ client
412
+ .execute(
413
+ "INSERT INTO hecks_backfill_progress (target, cursor, completed, updated_at) VALUES ($1, $2, $3, now()) \
414
+ ON CONFLICT (target) DO UPDATE SET cursor = EXCLUDED.cursor, completed = EXCLUDED.completed, updated_at = EXCLUDED.updated_at",
415
+ &[&target, &cursor, &completed],
416
+ )
417
+ .await?;
418
+ Ok(())
419
+ }
420
+
421
+ /// One chunk of `head_snapshot`'s backfill, SAVEPOINT-nested since this
422
+ /// always runs inside the mint transaction `mint_era` already opened.
423
+ async fn run_backfill_chunk<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32, target: &str) -> anyhow::Result<bool> {
424
+ client.batch_execute("SAVEPOINT hecks_backfill_chunk").await?;
425
+ let result: anyhow::Result<bool> = async {
426
+ client.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_field_cache:' || $1))", &[&target]).await?;
427
+ let (cursor, completed) = backfill_progress(client, target).await?;
428
+ if completed {
429
+ return Ok(true);
430
+ }
431
+
432
+ let journal = journal_table(domain);
433
+ let cursor_clause = cursor.as_deref().map(|c| format!(" AND aggregate_id > {}", text_literal(c))).unwrap_or_default();
434
+ let source_sql = format!(
435
+ "SELECT id, ordinal, state FROM (\
436
+ SELECT DISTINCT ON (aggregate_id) aggregate_id AS id, ordinal, operation, state \
437
+ FROM {} WHERE era = {era} AND aggregate = {}{cursor_clause} \
438
+ ORDER BY aggregate_id, ordinal DESC\
439
+ ) latest WHERE operation = 'save' ORDER BY id LIMIT {CHUNK_SIZE}",
440
+ quote_ident(&journal),
441
+ text_literal(storage_name)
442
+ );
443
+ let rows = client.query(&source_sql, &[]).await?;
444
+ if rows.is_empty() {
445
+ upsert_backfill_progress(client, target, cursor.as_deref(), true).await?;
446
+ return Ok(true);
447
+ }
448
+
449
+ let quoted_target = quote_ident(target);
450
+ for row in &rows {
451
+ let id: String = row.get("id");
452
+ let ordinal: i64 = row.get("ordinal");
453
+ let state: serde_json::Value = row.get("state");
454
+ client
455
+ .execute(
456
+ &format!(
457
+ "INSERT INTO {quoted_target} (id, ordinal, state) VALUES ($1, $2, $3) \
458
+ ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, state = EXCLUDED.state \
459
+ WHERE {quoted_target}.ordinal < EXCLUDED.ordinal"
460
+ ),
461
+ &[&id, &ordinal, &state],
462
+ )
463
+ .await?;
464
+ }
465
+ let done = (rows.len() as i64) < CHUNK_SIZE;
466
+ let last_cursor: String = rows.last().unwrap().get("id");
467
+ upsert_backfill_progress(client, target, Some(&last_cursor), done).await?;
468
+ Ok(done)
469
+ }
470
+ .await;
471
+
472
+ match &result {
473
+ Ok(_) => client.batch_execute("RELEASE SAVEPOINT hecks_backfill_chunk").await?,
474
+ Err(_) => client.batch_execute("ROLLBACK TO SAVEPOINT hecks_backfill_chunk").await?,
475
+ }
476
+ result
477
+ }
478
+
479
+ async fn backfill_head_snapshot<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32) -> anyhow::Result<()> {
480
+ let target = head_snapshot(domain, storage_name, era);
481
+ loop {
482
+ if run_backfill_chunk(client, domain, storage_name, era, &target).await? {
483
+ break;
484
+ }
485
+ }
486
+ Ok(())
487
+ }
488
+
489
+ async fn table_exists<C: GenericClient>(client: &C, name: &str) -> anyhow::Result<bool> {
490
+ let row = client
491
+ .query_opt("SELECT 1 FROM pg_class WHERE relname = $1 AND relkind = 'r' AND pg_table_is_visible(oid)", &[&name])
492
+ .await?;
493
+ Ok(row.is_some())
494
+ }
495
+
496
+ /// No backfill: adopting an era means its history already exists elsewhere.
497
+ /// Runs in its own transaction because `create_head_snapshot`'s SAVEPOINT needs one open.
498
+ pub(crate) async fn adopt_head_snapshots<C: GenericClient>(
499
+ client: &C,
500
+ domain: &str,
501
+ aggregates: &[Aggregate],
502
+ era: i32,
503
+ ) -> anyhow::Result<()> {
504
+ client.batch_execute("BEGIN").await.context("opening a transaction to adopt head snapshots")?;
505
+ let result: anyhow::Result<()> = async {
506
+ for aggregate in aggregates {
507
+ create_head_snapshot(client, domain, &aggregate.storage_name, era).await.with_context(|| {
508
+ format!("provisioning {domain}'s {} head snapshot for era {era}", aggregate.storage_name)
509
+ })?;
510
+ }
511
+ Ok(())
512
+ }
513
+ .await;
514
+ match &result {
515
+ Ok(_) => client.batch_execute("COMMIT").await?,
516
+ Err(_) => client.batch_execute("ROLLBACK").await?,
517
+ }
518
+ result
519
+ }
520
+
521
+ async fn ensure_head_snapshot<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32) -> anyhow::Result<()> {
522
+ create_head_snapshot(client, domain, storage_name, era).await?;
523
+ backfill_head_snapshot(client, domain, storage_name, era).await
524
+ }
525
+
526
+ /// The create half, shared by minting and adopting. Columns match Ruby's
527
+ /// own shape, so a Ruby runtime can still compile a head view over this table.
528
+ async fn create_head_snapshot<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32) -> anyhow::Result<()> {
529
+ let name = head_snapshot(domain, storage_name, era);
530
+ if !table_exists(client, &name).await? {
531
+ client.batch_execute("SAVEPOINT hecks_head_snapshot").await?;
532
+ let result: anyhow::Result<()> = async {
533
+ client.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_head_snapshot:' || $1))", &[&name]).await?;
534
+ if !table_exists(client, &name).await? {
535
+ client
536
+ .batch_execute(&format!(
537
+ "CREATE TABLE {} (id text PRIMARY KEY, ordinal bigint NOT NULL, \
538
+ operation text NOT NULL DEFAULT 'save', state jsonb)",
539
+ quote_ident(&name)
540
+ ))
541
+ .await?;
542
+ }
543
+ Ok(())
544
+ }
545
+ .await;
546
+ match &result {
547
+ Ok(_) => client.batch_execute("RELEASE SAVEPOINT hecks_head_snapshot").await?,
548
+ Err(_) => client.batch_execute("ROLLBACK TO SAVEPOINT hecks_head_snapshot").await?,
549
+ }
550
+ result?;
551
+ }
552
+ Ok(())
553
+ }
554
+
555
+ /// Which name/storage_name each aggregate carried at each era in the
556
+ /// chain, walked backward from the current (era N) name through each edge's `was:`.
557
+ fn names_by_era(current_name: &str, edges: &[&Edge]) -> (Vec<String>, Vec<String>) {
558
+ let mut current = vec![String::new(); edges.len() + 1];
559
+ current[edges.len()] = current_name.to_string();
560
+ for index in (0..edges.len()).rev() {
561
+ let declared = edges[index].for_aggregate(&current[index + 1]);
562
+ current[index] = declared.and_then(|d| d.was.clone()).unwrap_or_else(|| current[index + 1].clone());
563
+ }
564
+ let storage = current.iter().map(|name| storage_shape_snake(name)).collect();
565
+ (current, storage)
566
+ }
567
+
568
+ fn ancestor_tail_sql(domain: &str, era: i32, storage_names: &[String], watermarks: &std::collections::HashMap<i32, Option<i64>>) -> String {
569
+ let journal = quote_ident(&journal_table(domain));
570
+ (1..era)
571
+ .map(|ancestor| {
572
+ let cut = watermarks.get(&(ancestor + 1)).copied().flatten();
573
+ let cut_clause = cut.map(|c| format!(" AND ordinal <= {c}")).unwrap_or_default();
574
+ format!(
575
+ "SELECT ordinal, era, aggregate, aggregate_id, operation, state FROM {journal} \
576
+ WHERE era = {ancestor} AND aggregate = {}{cut_clause}",
577
+ text_literal(&storage_names[(ancestor - 1) as usize])
578
+ )
579
+ })
580
+ .collect::<Vec<_>>()
581
+ .join(" UNION ALL ")
582
+ }
583
+
584
+ fn latest_per_id(tail: &str) -> String {
585
+ if tail.is_empty() {
586
+ return tail.to_string();
587
+ }
588
+ format!("SELECT DISTINCT ON (aggregate_id) ordinal, era, aggregate, aggregate_id, operation, state FROM ({tail}) tail_entries ORDER BY aggregate_id, ordinal DESC")
589
+ }
590
+
591
+ // Each edge CTE is `MATERIALIZED` — unfenced, Postgres re-inlines every edge's
592
+ // expression at each read of `state`, and the plan grows exponentially with the chain.
593
+ fn chain_sql(domain: &str, current_name: &str, era: i32, edges: &[&Edge], watermarks: &std::collections::HashMap<i32, Option<i64>>) -> String {
594
+ chain_sql_with(domain, current_name, era, edges, watermarks, true)
595
+ }
596
+
597
+ fn chain_sql_with(
598
+ domain: &str,
599
+ current_name: &str,
600
+ era: i32,
601
+ edges: &[&Edge],
602
+ watermarks: &std::collections::HashMap<i32, Option<i64>>,
603
+ materialize_edges: bool,
604
+ ) -> String {
605
+ let fence = if materialize_edges { "MATERIALIZED " } else { "" };
606
+ let (current_names, storage_names) = names_by_era(current_name, edges);
607
+ let tail = latest_per_id(&ancestor_tail_sql(domain, era, &storage_names, watermarks));
608
+
609
+ let chain: Vec<String> = edges
610
+ .iter()
611
+ .enumerate()
612
+ .map(|(index, edge)| {
613
+ let declared = edge.for_aggregate(&current_names[index + 1]);
614
+ let expression = declared.map(|d| d.compiled_state_expression.clone()).unwrap_or_else(|| "state".to_string());
615
+ let guard = format!("era <= {} AND operation = 'save'", index + 1);
616
+ let id_column = match declared.filter(|d| d.compiled_id_expression.is_some()) {
617
+ Some(d) => format!(
618
+ "CASE WHEN {guard} THEN {} ELSE aggregate_id END AS aggregate_id",
619
+ d.compiled_id_expression.as_deref().unwrap()
620
+ ),
621
+ None => "aggregate_id".to_string(),
622
+ };
623
+ let from = if index == 0 { "tail".to_string() } else { format!("edge_{index}") };
624
+ format!(
625
+ "edge_{} AS {fence}(SELECT ordinal, era, {id_column}, operation, CASE WHEN {guard} THEN {expression} ELSE state END AS state FROM {from})",
626
+ index + 1
627
+ )
628
+ })
629
+ .collect();
630
+
631
+ format!("WITH tail AS {fence}({tail}),\n{}\nSELECT ordinal, aggregate_id, operation, state FROM edge_{}", chain.join(",\n"), edges.len())
632
+ }
633
+
634
+ /// Reduces a chain SQL to one row per id: the latest entry, only if it was a save.
635
+ async fn latest_of<C: GenericClient>(client: &C, inner_sql: &str) -> anyhow::Result<std::collections::HashMap<String, Value>> {
636
+ let sql = format!(
637
+ "SELECT aggregate_id, state FROM (\
638
+ SELECT DISTINCT ON (aggregate_id) aggregate_id, operation, state \
639
+ FROM ({inner_sql}) chained ORDER BY aggregate_id, ordinal DESC\
640
+ ) latest WHERE operation = 'save'"
641
+ );
642
+ let rows = client.query(&sql, &[]).await?;
643
+ Ok(rows.into_iter().map(|row| (row.get::<_, String>("aggregate_id"), row.get::<_, Value>("state"))).collect())
644
+ }
645
+
646
+ /// The translated tail at era `era`, latest per id, saves only — the audit's `after`.
647
+ async fn translated_latest<C: GenericClient>(
648
+ client: &C,
649
+ domain: &str,
650
+ current_name: &str,
651
+ era: i32,
652
+ edges: &[&Edge],
653
+ watermarks: &std::collections::HashMap<i32, Option<i64>>,
654
+ ) -> anyhow::Result<std::collections::HashMap<String, Value>> {
655
+ latest_of(client, &chain_sql(domain, current_name, era, edges, watermarks)).await
656
+ }
657
+
658
+ /// The untranslated ancestor tail, latest per id — the "before" side of the
659
+ /// preservation check. Era 1 has no ancestors, so the tail SQL is empty.
660
+ async fn ancestor_latest<C: GenericClient>(
661
+ client: &C,
662
+ domain: &str,
663
+ current_name: &str,
664
+ era: i32,
665
+ edges: &[&Edge],
666
+ watermarks: &std::collections::HashMap<i32, Option<i64>>,
667
+ ) -> anyhow::Result<std::collections::HashMap<String, Value>> {
668
+ let (_, storage_names) = names_by_era(current_name, edges);
669
+ let tail = ancestor_tail_sql(domain, era, &storage_names, watermarks);
670
+ if tail.is_empty() {
671
+ return Ok(std::collections::HashMap::new());
672
+ }
673
+ latest_of(client, &format!("SELECT ordinal, era, aggregate_id, operation, state FROM ({tail}) tail_rows")).await
674
+ }
675
+
676
+ /// One aggregate's raw rule JSON within one edge; `reference_transform::translate`
677
+ /// needs the declarative rules themselves, not just the compiled SQL.
678
+ fn raw_declared<'a>(raw_edges: &'a [Value], domain: &str, edge: &Edge, aggregate_name: &str) -> Option<&'a Value> {
679
+ raw_edges
680
+ .iter()
681
+ .find(|candidate| {
682
+ candidate.get("domain").and_then(Value::as_str) == Some(domain)
683
+ && candidate.get("from").and_then(Value::as_str) == Some(edge.from.as_str())
684
+ && candidate.get("to").and_then(Value::as_str) == Some(edge.to.as_str())
685
+ })
686
+ .and_then(|raw_edge| raw_edge.get("aggregates").and_then(Value::as_array))
687
+ .and_then(|aggs| aggs.iter().find(|agg| agg.get("name").and_then(Value::as_str) == Some(aggregate_name)))
688
+ }
689
+
690
+ /// Layer 2 of the mint audit for one aggregate: per-rule value preservation
691
+ /// plus id-set/count conservation across the edge. Appends to `violations`
692
+ /// rather than raising, so one mint reports everything wrong at once.
693
+ ///
694
+ /// `declared` is the raw rule JSON for the edge being minted this boot only —
695
+ /// earlier edges in a multi-hop drift were already minted and audited.
696
+ async fn audit_layer_two<C: GenericClient>(
697
+ client: &C,
698
+ domain: &str,
699
+ aggregate: &Aggregate,
700
+ ordinal: i32,
701
+ edges: &[&Edge],
702
+ raw_edges: &[Value],
703
+ watermarks: &std::collections::HashMap<i32, Option<i64>>,
704
+ after: &std::collections::HashMap<String, Value>,
705
+ violations: &mut Vec<String>,
706
+ ) -> anyhow::Result<()> {
707
+ let before = if edges.len() > 1 {
708
+ translated_latest(client, domain, &aggregate.name, ordinal, &edges[..edges.len() - 1], watermarks).await?
709
+ } else {
710
+ ancestor_latest(client, domain, &aggregate.name, ordinal, edges, watermarks).await?
711
+ };
712
+
713
+ let last_edge = edges.last().expect("mint always audits at least one edge");
714
+ let declared = raw_declared(raw_edges, domain, last_edge, &aggregate.name);
715
+ let rekeyed = declared.map(|d| !d.get("rekeys").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true)).unwrap_or(false);
716
+
717
+ if rekeyed {
718
+ if before.len() != after.len() {
719
+ violations.push(format!(
720
+ "{}: the record count changed across a rekeying edge ({} before, {} after) — a rekey must not \
721
+ collide two distinct ids onto one, or drop one",
722
+ aggregate.name,
723
+ before.len(),
724
+ after.len()
725
+ ));
726
+ }
727
+ } else {
728
+ let mut lost: Vec<&String> = before.keys().filter(|id| !after.contains_key(*id)).collect();
729
+ let mut gained: Vec<&String> = after.keys().filter(|id| !before.contains_key(*id)).collect();
730
+ if !lost.is_empty() || !gained.is_empty() {
731
+ lost.sort();
732
+ gained.sort();
733
+ violations.push(format!(
734
+ "{}: the id set changed across the edge (lost {:?}, gained {:?})",
735
+ aggregate.name, lost, gained
736
+ ));
737
+ }
738
+ }
739
+
740
+ let Some(declared) = declared else { return Ok(()) };
741
+ if rekeyed {
742
+ return Ok(());
743
+ }
744
+
745
+ let compute_tops: std::collections::HashSet<String> = declared
746
+ .get("computes")
747
+ .and_then(Value::as_array)
748
+ .map(|computes| {
749
+ computes
750
+ .iter()
751
+ .flat_map(|c| [c.get("from"), c.get("to")])
752
+ .flatten()
753
+ .filter_map(Value::as_str)
754
+ .map(|path| path.split('.').next().unwrap_or(path).to_string())
755
+ .collect()
756
+ })
757
+ .unwrap_or_default();
758
+
759
+ for (id, state) in &before {
760
+ let Some(actual_after) = after.get(id) else { continue };
761
+ let expected = reference_transform::translate(declared, state)?;
762
+ let expected = strip_keys(&expected, &compute_tops);
763
+ let actual = strip_keys(actual_after, &compute_tops);
764
+ if expected != actual {
765
+ let mut diverged: Vec<String> = expected
766
+ .as_object()
767
+ .into_iter()
768
+ .flat_map(|o| o.keys())
769
+ .chain(actual.as_object().into_iter().flat_map(|o| o.keys()))
770
+ .filter(|key| expected.get(key.as_str()) != actual.get(key.as_str()))
771
+ .cloned()
772
+ .collect();
773
+ diverged.sort();
774
+ diverged.dedup();
775
+ violations.push(format!("{}#{id}: the translated state diverges from the reference transform at {}", aggregate.name, diverged.join(", ")));
776
+ }
777
+ }
778
+ Ok(())
779
+ }
780
+
781
+ fn strip_keys(state: &Value, keys: &std::collections::HashSet<String>) -> Value {
782
+ match state {
783
+ Value::Object(map) => Value::Object(map.iter().filter(|(k, _)| !keys.contains(*k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
784
+ other => other.clone(),
785
+ }
786
+ }
787
+
788
+ /// The whole mint-time audit, over every lineage-capable aggregate. Runs
789
+ /// before anything is minted, so a refusal leaves no half-born era.
790
+ ///
791
+ /// `ir` is the whole `ir.json` value; Layer 1 looks up each aggregate's node from it.
792
+ pub async fn audit_before_mint<C: GenericClient>(
793
+ client: &C,
794
+ domain: &str,
795
+ ir: &Value,
796
+ aggregates: &[Aggregate],
797
+ ordinal: i32,
798
+ edges: &[&Edge],
799
+ raw_edges: &[Value],
800
+ watermarks: &std::collections::HashMap<i32, Option<i64>>,
801
+ ) -> anyhow::Result<()> {
802
+ let ir_aggregates: std::collections::HashMap<&str, &Value> = ir
803
+ .get("aggregates")
804
+ .and_then(Value::as_array)
805
+ .map(|list| list.iter().filter_map(|agg| agg.get("name").and_then(Value::as_str).map(|n| (n, agg))).collect())
806
+ .unwrap_or_default();
807
+
808
+ let mut violations = Vec::new();
809
+ for aggregate in aggregates {
810
+ let phase = crate::log::phase_with("audit_aggregate", serde_json::json!({ "aggregate": &aggregate.storage_name }));
811
+ let after = translated_latest(client, domain, &aggregate.name, ordinal, edges, watermarks).await?;
812
+
813
+ // Layer 1 — structural (types/patterns/admits/lifecycle) — over
814
+ // every record `after` actually holds, matching Ruby's own
815
+ // `layer_one!(violations, aggregate, after)` exactly.
816
+ if let Some(aggregate_ir) = ir_aggregates.get(aggregate.name.as_str()) {
817
+ for (id, state) in &after {
818
+ violations.extend(reference_validate::validate(aggregate_ir, id, state));
819
+ }
820
+ }
821
+
822
+ // Layer 2 — per-rule value preservation against the reference
823
+ // transform, id-set/count conservation.
824
+ audit_layer_two(client, domain, aggregate, ordinal, edges, raw_edges, watermarks, &after, &mut violations).await?;
825
+ phase.end_with(serde_json::json!({ "records": after.len() }));
826
+ }
827
+ if violations.is_empty() {
828
+ return Ok(());
829
+ }
830
+ anyhow::bail!("cannot mint era {ordinal} of {domain}: the audit refused —\n - {}", violations.join("\n - "));
831
+ }
832
+
833
+ /// `compile_head!` — one aggregate's matview + head-snapshot + head
834
+ /// view, for one era, over the full (never layered) chain.
835
+ async fn compile_head<C: GenericClient>(
836
+ client: &C,
837
+ domain: &str,
838
+ aggregate: &Aggregate,
839
+ era: i32,
840
+ label: &str,
841
+ edges: &[&Edge],
842
+ watermarks: &std::collections::HashMap<i32, Option<i64>>,
843
+ ) -> anyhow::Result<()> {
844
+ let storage_name = &aggregate.storage_name;
845
+ let view = matview(domain, storage_name, era, label);
846
+ let body = chain_sql(domain, &aggregate.name, era, edges, watermarks);
847
+
848
+ client.batch_execute(&format!("CREATE MATERIALIZED VIEW {} AS\n{body}", quote_ident(&view))).await?;
849
+ client
850
+ .batch_execute(&format!(
851
+ "CREATE INDEX IF NOT EXISTS {} ON {} (aggregate_id, ordinal DESC)",
852
+ quote_ident(&format!("{view}_reduce_idx")),
853
+ quote_ident(&view)
854
+ ))
855
+ .await?;
856
+
857
+ ensure_head_snapshot(client, domain, storage_name, era).await?;
858
+
859
+ client.batch_execute(&format!("DROP VIEW IF EXISTS {}", quote_ident(&head_view(domain, storage_name)))).await?;
860
+ client
861
+ .batch_execute(&format!(
862
+ "CREATE VIEW {} AS \
863
+ SELECT id, state FROM (\
864
+ SELECT DISTINCT ON (aggregate_id) aggregate_id AS id, operation, state FROM (\
865
+ SELECT ordinal, aggregate_id, operation, state FROM {} \
866
+ UNION ALL \
867
+ SELECT ordinal, id AS aggregate_id, 'save' AS operation, state FROM {}\
868
+ ) merged ORDER BY aggregate_id, ordinal DESC\
869
+ ) latest WHERE operation = 'save'",
870
+ quote_ident(&head_view(domain, storage_name)),
871
+ quote_ident(&view),
872
+ quote_ident(&head_snapshot(domain, storage_name, era))
873
+ ))
874
+ .await?;
875
+ Ok(())
876
+ }
877
+ /// Mints era 1: fresh domain, no edge, never needs the approval gate.
878
+ pub async fn hold_first<C: GenericClient>(client: &C, domain: &str, held_text: &str, ir: &Value, aggregates: &[Aggregate], role: Option<&str>) -> anyhow::Result<()> {
879
+ ensure_base(client, domain).await?;
880
+
881
+ // Own transaction: `ensure_first_head`'s `ensure_head_snapshot` backfill
882
+ // loop is SAVEPOINT-shaped and needs one open.
883
+ client.batch_execute("BEGIN").await?;
884
+ let result = hold_first_body(client, domain, held_text, ir, aggregates, role).await;
885
+ match &result {
886
+ Ok(_) => client.batch_execute("COMMIT").await?,
887
+ Err(_) => {
888
+ let _ = client.batch_execute("ROLLBACK").await;
889
+ }
890
+ }
891
+ result
892
+ }
893
+
894
+ async fn hold_first_body<C: GenericClient>(client: &C, domain: &str, held_text: &str, ir: &Value, aggregates: &[Aggregate], role: Option<&str>) -> anyhow::Result<()> {
895
+ let hash = storage_shape::mint_hash(ir);
896
+ let label = storage_shape::mint_label(ir);
897
+ let digest = format!("{:x}", Sha256::digest(held_text.as_bytes()));
898
+
899
+ client
900
+ .execute(
901
+ "INSERT INTO hecks_eras (domain, ordinal, hash, label, held_text, watermark, held_digest, canon_form) \
902
+ VALUES ($1, 1, $2, $3, $4, 0, $5, $6) ON CONFLICT DO NOTHING",
903
+ &[&domain, &hash, &label, &held_text, &digest, &storage_shape::FORM_VERSION],
904
+ )
905
+ .await?;
906
+ archive_text(client, domain, 1, held_text, &digest).await?;
907
+ for aggregate in aggregates {
908
+ ensure_first_head(client, domain, &aggregate.storage_name).await?;
909
+ }
910
+ advance_era(client, domain, 1).await?;
911
+ if let Some(role) = role {
912
+ grant_role(client, domain, role, &[], None).await?;
913
+ }
914
+ Ok(())
915
+ }
916
+
917
+ /// `ensure_first_head!` — era 1's head is a plain view over its own
918
+ /// (immediately-created) snapshot table, no chain to compile, no
919
+ /// translation possible from nothing.
920
+ async fn ensure_first_head<C: GenericClient>(client: &C, domain: &str, storage_name: &str) -> anyhow::Result<()> {
921
+ ensure_head_snapshot(client, domain, storage_name, 1).await?;
922
+ client
923
+ .batch_execute(&format!(
924
+ "CREATE OR REPLACE VIEW {} AS SELECT id, state FROM {}",
925
+ quote_ident(&head_view(domain, storage_name)),
926
+ quote_ident(&head_snapshot(domain, storage_name, 1))
927
+ ))
928
+ .await?;
929
+ Ok(())
930
+ }
931
+
932
+ async fn archive_text<C: GenericClient>(client: &C, domain: &str, ordinal: i32, text: &str, digest: &str) -> anyhow::Result<()> {
933
+ client
934
+ .execute(
935
+ "INSERT INTO hecks_era_texts (domain, ordinal, digest, held_text) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
936
+ &[&domain, &ordinal, &digest, &text],
937
+ )
938
+ .await?;
939
+ Ok(())
940
+ }
941
+
942
+ pub(crate) async fn advance_era<C: GenericClient>(client: &C, domain: &str, ordinal: i32) -> anyhow::Result<()> {
943
+ let journal = quote_ident(&journal_table(domain));
944
+ client.batch_execute(&format!("DROP POLICY IF EXISTS hecks_current_era ON {journal}")).await?;
945
+ client
946
+ .batch_execute(&format!("CREATE POLICY hecks_current_era ON {journal} FOR INSERT TO PUBLIC WITH CHECK (era = {ordinal})"))
947
+ .await?;
948
+ client.batch_execute(&format!("DROP POLICY IF EXISTS hecks_read_all ON {journal}")).await?;
949
+ client.batch_execute(&format!("CREATE POLICY hecks_read_all ON {journal} FOR SELECT TO PUBLIC USING (true)")).await?;
950
+ Ok(())
951
+ }
952
+
953
+ async fn grant_role<C: GenericClient>(client: &C, domain: &str, role: &str, aggregates: &[Aggregate], era: Option<i32>) -> anyhow::Result<()> {
954
+ let journal = journal_table(domain);
955
+ if !provisioner(client, &journal).await? {
956
+ return Ok(());
957
+ }
958
+ let quoted_role = quote_ident(role);
959
+ client.batch_execute(&format!("GRANT INSERT, SELECT ON {} TO {quoted_role}", quote_ident(&journal))).await?;
960
+ client.batch_execute(&format!("GRANT USAGE ON SEQUENCE {} TO {quoted_role}", quote_ident(&sequence(domain)))).await?;
961
+ let Some(era) = era else { return Ok(()) };
962
+ for aggregate in aggregates {
963
+ let storage_name = &aggregate.storage_name;
964
+ client
965
+ .batch_execute(&format!(
966
+ "GRANT SELECT, INSERT, UPDATE, DELETE ON {} TO {quoted_role}",
967
+ quote_ident(&head_snapshot(domain, storage_name, era))
968
+ ))
969
+ .await?;
970
+ let view = head_view(domain, storage_name);
971
+ if table_or_view_exists(client, &view).await? {
972
+ client.batch_execute(&format!("GRANT SELECT ON {} TO {quoted_role}", quote_ident(&view))).await?;
973
+ }
974
+ }
975
+ Ok(())
976
+ }
977
+
978
+ async fn table_or_view_exists<C: GenericClient>(client: &C, name: &str) -> anyhow::Result<bool> {
979
+ let row = client
980
+ .query_opt("SELECT 1 FROM pg_class WHERE relname = $1 AND relkind IN ('v', 'm') AND pg_table_is_visible(oid)", &[&name])
981
+ .await?;
982
+ Ok(row.is_some())
983
+ }
984
+
985
+ /// The one transaction that makes era `ordinal` real. Approval-gate checking
986
+ /// is the caller's job; this function mints once asked to.
987
+ #[allow(clippy::too_many_arguments)]
988
+ pub async fn mint_era<C: GenericClient>(
989
+ client: &C,
990
+ domain: &str,
991
+ ordinal: i32,
992
+ hash: &str,
993
+ label: &str,
994
+ held_text: &str,
995
+ aggregates: &[Aggregate],
996
+ edges: &[&Edge],
997
+ role: Option<&str>,
998
+ lifecycle_defaults: &[LifecycleDefault],
999
+ ) -> anyhow::Result<()> {
1000
+ client.batch_execute("BEGIN").await?;
1001
+ let result = mint_era_body(client, domain, ordinal, hash, label, held_text, aggregates, edges, role, lifecycle_defaults).await;
1002
+ match &result {
1003
+ Ok(_) => {
1004
+ let phase = crate::log::phase_with("mint_commit", serde_json::json!({ "era": ordinal }));
1005
+ client.batch_execute("COMMIT").await?;
1006
+ phase.end();
1007
+ }
1008
+ Err(_) => {
1009
+ let _ = client.batch_execute("ROLLBACK").await;
1010
+ }
1011
+ }
1012
+ result
1013
+ }
1014
+
1015
+ #[allow(clippy::too_many_arguments)]
1016
+ async fn mint_era_body<C: GenericClient>(
1017
+ client: &C,
1018
+ domain: &str,
1019
+ ordinal: i32,
1020
+ hash: &str,
1021
+ label: &str,
1022
+ held_text: &str,
1023
+ aggregates: &[Aggregate],
1024
+ edges: &[&Edge],
1025
+ role: Option<&str>,
1026
+ lifecycle_defaults: &[LifecycleDefault],
1027
+ ) -> anyhow::Result<()> {
1028
+ client.batch_execute("SET LOCAL lock_timeout = '10s'").await?;
1029
+ client
1030
+ .execute(&format!("SELECT pg_advisory_xact_lock(hashtext('hecks_eras:' || {}))", text_literal(domain)), &[])
1031
+ .await?;
1032
+
1033
+ let already_minted = client.query_opt("SELECT 1 FROM hecks_eras WHERE domain = $1 AND ordinal = $2", &[&domain, &ordinal]).await?;
1034
+ if already_minted.is_some() {
1035
+ anyhow::bail!("era {ordinal} of {domain} is already minted — nothing to do");
1036
+ }
1037
+
1038
+ let watermark = crate::journal::last_ordinal(client, domain).await?;
1039
+ let digest = format!("{:x}", Sha256::digest(held_text.as_bytes()));
1040
+ client
1041
+ .execute(
1042
+ "INSERT INTO hecks_eras (domain, ordinal, hash, label, held_text, watermark, held_digest, canon_form) \
1043
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
1044
+ &[&domain, &ordinal, &hash, &label, &held_text, &watermark, &digest, &storage_shape::FORM_VERSION],
1045
+ )
1046
+ .await?;
1047
+ archive_text(client, domain, ordinal, held_text, &digest).await?;
1048
+ ensure_partition(client, domain, ordinal).await?;
1049
+
1050
+ // Every held era's watermark, for `ancestor_tail_sql`'s cut — includes
1051
+ // the row just inserted, which bounds era `ordinal - 1`'s tail read.
1052
+ let held = crate::journal::held_eras(client, domain).await?;
1053
+ let watermarks: std::collections::HashMap<i32, Option<i64>> = held.iter().map(|era| (era.ordinal, era.watermark)).collect();
1054
+
1055
+ for aggregate in aggregates {
1056
+ let phase = crate::log::phase_with("compile_head", serde_json::json!({ "aggregate": &aggregate.storage_name, "era": ordinal }));
1057
+ compile_head(client, domain, aggregate, ordinal, label, edges, &watermarks).await?;
1058
+ phase.end();
1059
+ }
1060
+
1061
+ // Same transaction as the era row and the fence flip: a boot that sees
1062
+ // the new era also sees a snapshot the new shape can read.
1063
+ let phase = crate::log::phase_with("snapshot_fill", serde_json::json!({ "era": ordinal }));
1064
+ let filled = fill_snapshot_lifecycle_defaults(client, domain, lifecycle_defaults).await?;
1065
+ phase.end_with(serde_json::json!({ "instances_filled": filled }));
1066
+
1067
+ if let Some(role) = role {
1068
+ grant_role(client, domain, role, aggregates, Some(ordinal)).await?;
1069
+ }
1070
+ advance_era(client, domain, ordinal).await?;
1071
+ Ok(())
1072
+ }
1073
+
1074
+ /// A lifecycle field an aggregate's instances must carry, and its default
1075
+ /// for new instances. `key_prefix` matches the kernel's `instances()` dump key shape.
1076
+ #[derive(Debug, Clone, PartialEq)]
1077
+ pub struct LifecycleDefault {
1078
+ pub key_prefix: String,
1079
+ pub field: String,
1080
+ pub default: String,
1081
+ }
1082
+
1083
+ /// Every aggregate's lifecycle field and default, read from `ir.json`.
1084
+ /// An aggregate with no lifecycle (or no string default) contributes nothing.
1085
+ pub fn lifecycle_defaults(ir: &Value) -> Vec<LifecycleDefault> {
1086
+ let domain_name = ir.get("name").and_then(Value::as_str).unwrap_or_default();
1087
+ ir.get("aggregates")
1088
+ .and_then(Value::as_array)
1089
+ .map(|aggregates| {
1090
+ aggregates
1091
+ .iter()
1092
+ .filter_map(|aggregate| {
1093
+ let name = aggregate.get("name")?.as_str()?;
1094
+ let lifecycle = aggregate.get("lifecycle")?;
1095
+ let field = lifecycle.get("field")?.as_str()?;
1096
+ let default = lifecycle.get("default")?.as_str()?;
1097
+ Some(LifecycleDefault {
1098
+ key_prefix: format!("{domain_name}::{name}#"),
1099
+ field: field.to_string(),
1100
+ default: default.to_string(),
1101
+ })
1102
+ })
1103
+ .collect()
1104
+ })
1105
+ .unwrap_or_default()
1106
+ }
1107
+
1108
+ /// Fills a missing lifecycle field into every seeded instance; returns how
1109
+ /// many changed. Idempotent, since the kernel's `from_json` requires the field on load.
1110
+ pub fn fill_lifecycle_defaults(seed: &mut Value, defaults: &[LifecycleDefault]) -> usize {
1111
+ let Some(instances) = seed.as_object_mut() else { return 0 };
1112
+ let mut changed = 0;
1113
+ for (key, instance) in instances.iter_mut() {
1114
+ let Some(default) = defaults.iter().find(|d| key.starts_with(&d.key_prefix)) else { continue };
1115
+ let Some(fields) = instance.as_object_mut() else { continue };
1116
+ if fields.get(&default.field).is_none_or(Value::is_null) {
1117
+ fields.insert(default.field.clone(), Value::String(default.default.clone()));
1118
+ changed += 1;
1119
+ }
1120
+ }
1121
+ changed
1122
+ }
1123
+
1124
+ /// Fills `defaults` into the stored kernel snapshot, inside the caller's
1125
+ /// transaction — without this, a request right after the mint can hit
1126
+ /// `invalid seed` on the old-shaped snapshot. Takes the same advisory lock a
1127
+ /// dispatch holds, so an in-flight dispatch saves first.
1128
+ async fn fill_snapshot_lifecycle_defaults<C: GenericClient>(client: &C, domain: &str, defaults: &[LifecycleDefault]) -> anyhow::Result<usize> {
1129
+ if defaults.is_empty() {
1130
+ return Ok(0);
1131
+ }
1132
+ let table_exists: bool = client.query_one("SELECT to_regclass('hecks_lambda_snapshot') IS NOT NULL", &[]).await?.get(0);
1133
+ if !table_exists {
1134
+ return Ok(0);
1135
+ }
1136
+ client.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_lambda_journal.' || $1::text))", &[&domain]).await?;
1137
+ let Some(row) = client.query_opt("SELECT seed FROM hecks_lambda_snapshot FOR UPDATE", &[]).await? else { return Ok(0) };
1138
+ let mut seed: Value = row.get(0);
1139
+ let changed = fill_lifecycle_defaults(&mut seed, defaults);
1140
+ if changed > 0 {
1141
+ client.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&seed]).await.context("filling lifecycle defaults into the snapshot")?;
1142
+ }
1143
+ Ok(changed)
1144
+ }
1145
+
1146
+ #[cfg(test)]
1147
+ mod tests {
1148
+
1149
+ /// A throwaway database of its own — `mint.rs` also compiles into
1150
+ /// `bin/mint_harness`, which has no `dispatch` module to borrow a helper from.
1151
+ async fn own_scratch_db(name: &str) -> tokio_postgres::Client {
1152
+ let (admin, connection) =
1153
+ tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await.expect("connect as admin");
1154
+ tokio::spawn(async move {
1155
+ let _ = connection.await;
1156
+ });
1157
+ let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")).await;
1158
+ admin.batch_execute(&format!("CREATE DATABASE {name}")).await.expect("create scratch db");
1159
+ let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls)
1160
+ .await
1161
+ .expect("connect to scratch db");
1162
+ tokio::spawn(async move {
1163
+ let _ = connection.await;
1164
+ });
1165
+ client
1166
+ }
1167
+
1168
+ /// Regression: a Ruby-minted era 2 left every domain-qualified head
1169
+ /// snapshot stopped at era 1, so the first write died on a missing relation.
1170
+ #[tokio::test]
1171
+ async fn adopting_an_era_someone_else_minted_provisions_the_head_snapshots_it_is_missing() {
1172
+ let guard = own_scratch_db("hecks_host_adopt_head_snapshots").await;
1173
+ // Era 1's snapshot only — exactly the shape a Ruby-minted era 2
1174
+ // leaves behind for this crate's own domain-qualified naming.
1175
+ super::adopt_head_snapshots(
1176
+ &guard,
1177
+ "SampleApp",
1178
+ &[super::Aggregate { name: "StateStyle".to_string(), storage_name: "state_style".to_string() }],
1179
+ 1,
1180
+ )
1181
+ .await
1182
+ .expect("era 1 exists, as Ruby left it");
1183
+
1184
+ let missing = super::head_snapshot("SampleApp", "state_style", 2);
1185
+ assert!(!super::table_exists(&guard, &missing).await.expect("a lookup"), "era 2 starts absent");
1186
+
1187
+ let aggregates =
1188
+ vec![super::Aggregate { name: "StateStyle".to_string(), storage_name: "state_style".to_string() }];
1189
+ super::adopt_head_snapshots(&guard, "SampleApp", &aggregates, 2).await.expect("adopts");
1190
+
1191
+ assert!(super::table_exists(&guard, &missing).await.expect("a lookup"), "era 2 is provisioned now");
1192
+
1193
+ // Idempotent — every boot runs it, the same way Ruby's does.
1194
+ super::adopt_head_snapshots(&guard, "SampleApp", &aggregates, 2).await.expect("adopts again");
1195
+ }
1196
+
1197
+ /// The columns match Ruby's, so a table this crate creates is one Ruby can
1198
+ /// still compile a head view over and write a delete tombstone into.
1199
+ #[tokio::test]
1200
+ async fn a_head_snapshot_this_crate_creates_carries_rubys_own_columns() {
1201
+ let guard = own_scratch_db("hecks_host_head_snapshot_columns").await;
1202
+ let aggregates = vec![super::Aggregate { name: "Widget".to_string(), storage_name: "widget".to_string() }];
1203
+ super::adopt_head_snapshots(&guard, "Fixtures", &aggregates, 1).await.expect("adopts");
1204
+
1205
+ let name = super::head_snapshot("Fixtures", "widget", 1);
1206
+ let rows = guard
1207
+ .query(
1208
+ "SELECT column_name, is_nullable, column_default FROM information_schema.columns \
1209
+ WHERE table_name = $1 ORDER BY ordinal_position",
1210
+ &[&name],
1211
+ )
1212
+ .await
1213
+ .expect("columns");
1214
+ let columns: Vec<String> = rows.iter().map(|r| r.get::<_, String>(0)).collect();
1215
+ assert_eq!(columns, vec!["id", "ordinal", "operation", "state"]);
1216
+
1217
+ let operation = rows.iter().find(|r| r.get::<_, String>(0) == "operation").expect("operation");
1218
+ assert_eq!(operation.get::<_, String>(1), "NO", "operation is NOT NULL");
1219
+ assert!(operation.get::<_, Option<String>>(2).unwrap_or_default().contains("save"), "defaults to 'save'");
1220
+
1221
+ let state = rows.iter().find(|r| r.get::<_, String>(0) == "state").expect("state");
1222
+ assert_eq!(state.get::<_, String>(1), "YES", "state is nullable, for a delete tombstone");
1223
+ }
1224
+ use super::*;
1225
+ use crate::journal;
1226
+ use tokio_postgres::NoTls;
1227
+
1228
+ fn rule(name: &str, was: Option<&str>, expression: &str) -> EdgeAggregate {
1229
+ EdgeAggregate { name: name.to_string(), was: was.map(str::to_string), has_compute_or_rekey: false, compiled_state_expression: expression.to_string(), compiled_id_expression: None }
1230
+ }
1231
+
1232
+ // A multi-hop chain: era1 "Pizza" -> era2 renamed to "Order" -> era3 an
1233
+ // attribute-only edge on "Order". `names_by_era` must walk this backward
1234
+ // correctly for chain_sql's per-ancestor filter to read the right era.
1235
+ #[test]
1236
+ fn names_by_era_walks_a_multi_hop_rename_chain_backward_correctly() {
1237
+ let edge_1_to_2 = Edge { from: "aaa".to_string(), to: "bbb".to_string(), aggregates: vec![rule("Order", Some("Pizza"), "hecks_tr_rename(state, 'x', 'y')")] };
1238
+ let edge_2_to_3 = Edge { from: "bbb".to_string(), to: "ccc".to_string(), aggregates: vec![rule("Order", None, "hecks_tr_rename(state, 'y', 'z')")] };
1239
+ let edges: Vec<&Edge> = vec![&edge_1_to_2, &edge_2_to_3];
1240
+
1241
+ let (current, storage) = names_by_era("Order", &edges);
1242
+
1243
+ // era 1 (index 0): the old name, "Pizza" -- read from edge_1_to_2's
1244
+ // own `was:`. era 2 (index 1) and era 3 (index 2): "Order",
1245
+ // unchanged since nothing renamed it again.
1246
+ assert_eq!(current, vec!["Pizza".to_string(), "Order".to_string(), "Order".to_string()]);
1247
+ assert_eq!(storage, vec!["pizza".to_string(), "order".to_string(), "order".to_string()]);
1248
+ }
1249
+
1250
+ #[test]
1251
+ fn edge_chain_finds_the_real_path_and_refuses_a_broken_one() {
1252
+ let edge = Edge { from: "aaa".to_string(), to: "bbb".to_string(), aggregates: vec![] };
1253
+ let edges = vec![edge.clone()];
1254
+
1255
+ let found = edge_chain(&edges, &["aaa".to_string(), "bbb".to_string()]).expect("a real edge connects aaa to bbb");
1256
+ assert_eq!(found.len(), 1);
1257
+ assert_eq!(found[0].to, "bbb");
1258
+
1259
+ let broken = edge_chain(&edges, &["aaa".to_string(), "zzz".to_string()]);
1260
+ assert!(broken.is_err(), "no edge leads aaa to zzz -- must refuse, not silently skip");
1261
+ }
1262
+
1263
+ fn held(ordinal: i32, label: Option<&str>) -> journal::HeldEra {
1264
+ journal::HeldEra { ordinal, hash: label.map(|_| "irrelevant".to_string()), label: label.map(str::to_string), held_text: String::new(), watermark: None }
1265
+ }
1266
+
1267
+ // The four branches `main.rs`'s boot gate calls into — pure, so each
1268
+ // one is provable without a real Postgres.
1269
+ #[test]
1270
+ fn decide_boot_action_covers_all_four_branches() {
1271
+ // No held eras at all -- brand new domain, hold era 1.
1272
+ assert_eq!(decide_boot_action(&[], "abc123"), BootDecision::HoldFirst);
1273
+
1274
+ // A held era already names this exact shape -- boot at it,
1275
+ // even if it's not the latest one (an operator could roll back
1276
+ // to an older binary whose shape a prior era already covers).
1277
+ let already_named = vec![held(1, Some("aaa111")), held(2, Some("bbb222"))];
1278
+ assert_eq!(decide_boot_action(&already_named, "aaa111"), BootDecision::UseExisting { ordinal: 1 });
1279
+ assert_eq!(decide_boot_action(&already_named, "bbb222"), BootDecision::UseExisting { ordinal: 2 });
1280
+
1281
+ // Held, but the latest doesn't match this binary's shape -- mint
1282
+ // the next ordinal from the latest's own label.
1283
+ let drifted = vec![held(1, Some("aaa111"))];
1284
+ assert_eq!(
1285
+ decide_boot_action(&drifted, "ccc333"),
1286
+ BootDecision::Mint { ordinal: 2, from_ordinal: 1, from_label: "aaa111".to_string() }
1287
+ );
1288
+
1289
+ // The latest held era has no label yet (Ruby minted it, never
1290
+ // named it) -- refuse rather than guess whether it matches.
1291
+ let unnamed = vec![held(1, Some("aaa111")), held(2, None)];
1292
+ assert_eq!(decide_boot_action(&unnamed, "ccc333"), BootDecision::LatestUnnamed { ordinal: 2 });
1293
+ }
1294
+
1295
+ // Proves Rust needs no Ruby to mint at all: real Postgres, zero Ruby
1296
+ // process — `hold_first` for era 1, a real write, `mint_era` for era 2
1297
+ // via a hand-built rename edge, then a read proving the translation ran.
1298
+ #[tokio::test]
1299
+ async fn rust_mints_both_eras_itself_writes_and_reads_back_the_translated_data() {
1300
+ let db = "rust_host_self_mint_test";
1301
+ let owner = "rust_host_self_mint_owner";
1302
+
1303
+ let admin = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await;
1304
+ let (admin_client, admin_connection) = admin.expect("connect to postgres as admin");
1305
+ tokio::spawn(async move {
1306
+ let _ = admin_connection.await;
1307
+ });
1308
+ let _ = admin_client.batch_execute(&format!("DROP DATABASE IF EXISTS {db} WITH (FORCE)")).await;
1309
+ admin_client.batch_execute(&format!("CREATE DATABASE {db}")).await.expect("create scratch db");
1310
+ let _ = admin_client.batch_execute(&format!("DROP ROLE IF EXISTS {owner}")).await;
1311
+ admin_client.batch_execute(&format!("CREATE ROLE {owner} {}", crate::test_pg::login_clause())).await.expect("create owner role");
1312
+ admin_client.batch_execute(&format!("GRANT CONNECT ON DATABASE {db} TO {owner}")).await.expect("grant connect");
1313
+
1314
+ let grant = tokio_postgres::connect(&crate::test_pg::conninfo(&db), NoTls).await;
1315
+ let (grant_client, grant_connection) = grant.expect("connect to scratch db as superuser to grant schema rights");
1316
+ tokio::spawn(async move {
1317
+ let _ = grant_connection.await;
1318
+ });
1319
+ grant_client.batch_execute(&format!("GRANT USAGE, CREATE ON SCHEMA public TO {owner}")).await.expect("grant schema rights");
1320
+ grant_client.batch_execute(&format!("ALTER DATABASE {db} OWNER TO {owner}")).await.expect("make owner the db owner");
1321
+
1322
+ let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo_as(&db, &owner), NoTls).await.expect("connect as owner");
1323
+ tokio::spawn(async move {
1324
+ let _ = connection.await;
1325
+ });
1326
+
1327
+ let domain = "SelfMint";
1328
+
1329
+ fn shape(attribute_name: &str) -> Value {
1330
+ serde_json::json!({
1331
+ "name": "SelfMint",
1332
+ "aggregates": [{
1333
+ "name": "Widget",
1334
+ "identified_by": ["kind"],
1335
+ "attributes": [
1336
+ {"name": attribute_name, "type": "Money", "list": false},
1337
+ {"name": "kind", "type": "Kind", "list": false}
1338
+ ],
1339
+ "value_objects": [
1340
+ {"name": "Money", "attributes": [{"name": "cents", "type": "Integer", "list": false}]},
1341
+ {"name": "Kind", "attributes": [{"name": "label", "type": "String", "list": false}]}
1342
+ ],
1343
+ "entities": []
1344
+ }]
1345
+ })
1346
+ }
1347
+ let v1_ir = shape("cost");
1348
+ let v2_ir = shape("amount");
1349
+
1350
+ // era 1: Rust mints it, no Ruby, no edge needed.
1351
+ let aggregate = Aggregate { name: "Widget".to_string(), storage_name: "widget".to_string() };
1352
+ hold_first(&client, domain, "v1 source text (opaque to this crate)", &v1_ir, &[aggregate.clone()], None).await.expect("hold_first");
1353
+
1354
+ let config = journal::LineageConfig { domain: domain.to_string(), era: Some(1), mirrored: None };
1355
+ journal::append_lineage_mutation(
1356
+ &client,
1357
+ &config,
1358
+ &journal::Mutation { aggregate: "Widget", id: "w1", operation: "save", state: &serde_json::json!({"cost": {"cents": 100}, "kind": {"label": "w1"}}) },
1359
+ )
1360
+ .await
1361
+ .expect("write w1 under era 1");
1362
+ journal::append_lineage_mutation(
1363
+ &client,
1364
+ &config,
1365
+ &journal::Mutation { aggregate: "Widget", id: "w2", operation: "save", state: &serde_json::json!({"cost": {"cents": 200}, "kind": {"label": "w2"}}) },
1366
+ )
1367
+ .await
1368
+ .expect("write w2 under era 1");
1369
+
1370
+ // era 2: Rust mints it too, via a hand-built rename edge, constructed
1371
+ // directly here since this test proves the mint mechanics, not the
1372
+ // build-time export pipeline (storage_shape.rs's own job).
1373
+ let from_label = storage_shape::mint_label(&v1_ir);
1374
+ let to_label = storage_shape::mint_label(&v2_ir);
1375
+ let edge = Edge {
1376
+ from: from_label,
1377
+ to: to_label.clone(),
1378
+ aggregates: vec![EdgeAggregate {
1379
+ name: "Widget".to_string(),
1380
+ was: None,
1381
+ has_compute_or_rekey: false,
1382
+ compiled_state_expression: "hecks_tr_rename(state, 'cost', 'amount')".to_string(),
1383
+ compiled_id_expression: None,
1384
+ }],
1385
+ };
1386
+
1387
+ mint_era(&client, domain, 2, &storage_shape::mint_hash(&v2_ir), &to_label, "v2 source text", &[aggregate], &[&edge], None, &[])
1388
+ .await
1389
+ .expect("mint_era");
1390
+
1391
+ // Read back through the same generic function real deployment traffic
1392
+ // uses, proving the whole chain, not just that SQL ran.
1393
+ let mut rows = journal::read_lineage_head_all(&client, domain, "widget").await.expect("read_lineage_head_all");
1394
+ rows.sort_by(|a, b| a.0.cmp(&b.0));
1395
+ assert_eq!(
1396
+ rows,
1397
+ vec![
1398
+ ("w1".to_string(), serde_json::json!({"amount": {"cents": 100}, "kind": {"label": "w1"}})),
1399
+ ("w2".to_string(), serde_json::json!({"amount": {"cents": 200}, "kind": {"label": "w2"}})),
1400
+ ]
1401
+ );
1402
+ }
1403
+
1404
+ fn registration_default() -> LifecycleDefault {
1405
+ LifecycleDefault { key_prefix: "Studio::Registration#".to_string(), field: "status".to_string(), default: "active".to_string() }
1406
+ }
1407
+
1408
+ // A seed as the kernel's `instances()` dump writes it: one entry per
1409
+ // instance under "Domain::Aggregate#id", value objects as nested objects,
1410
+ // a lifecycle field as a plain string.
1411
+ fn old_seed() -> Value {
1412
+ serde_json::json!({
1413
+ "Studio::Event#yoga": {
1414
+ "slug": {"value": "yoga"}, "name": {"value": "Yoga"}, "price": {"cents": 100},
1415
+ "capacity": {"value": 10}, "status": "open"
1416
+ },
1417
+ "Studio::Registration#r1": {
1418
+ "event_slug": "yoga", "registration_id": {"value": "r1"},
1419
+ "attendee": {"name": "Ada", "email": "ada@example.com"}
1420
+ },
1421
+ "Studio::Registration#r2": {
1422
+ "event_slug": "yoga", "registration_id": {"value": "r2"},
1423
+ "attendee": {"name": "Grace", "email": "grace@example.com"}
1424
+ },
1425
+ "Payments::Payment#r1": {"status": "succeeded"}
1426
+ })
1427
+ }
1428
+
1429
+ #[test]
1430
+ fn a_missing_lifecycle_field_gets_the_default() {
1431
+ let mut seed = old_seed();
1432
+ assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 2);
1433
+ assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
1434
+ assert_eq!(seed["Studio::Registration#r2"]["status"], "active");
1435
+ assert_eq!(seed["Studio::Registration#r1"]["attendee"]["email"], "ada@example.com", "everything else on the instance is kept");
1436
+ }
1437
+
1438
+ #[test]
1439
+ fn a_null_lifecycle_field_is_filled_too() {
1440
+ let mut seed = serde_json::json!({"Studio::Registration#r1": {"status": null}});
1441
+ assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 1);
1442
+ assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
1443
+ }
1444
+
1445
+ #[test]
1446
+ fn an_existing_lifecycle_value_is_left_alone() {
1447
+ let mut seed = old_seed();
1448
+ seed["Studio::Registration#r2"]["status"] = serde_json::json!("archived");
1449
+ assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 1);
1450
+ assert_eq!(seed["Studio::Registration#r2"]["status"], "archived");
1451
+ assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
1452
+ }
1453
+
1454
+ #[test]
1455
+ fn aggregates_without_a_declared_default_are_untouched() {
1456
+ let mut seed = old_seed();
1457
+ let before = seed.clone();
1458
+ assert_eq!(fill_lifecycle_defaults(&mut seed, &[]), 0);
1459
+ assert_eq!(seed, before);
1460
+
1461
+ // Only Registration is named: the Event, the Payment, and an aggregate
1462
+ // whose name merely starts with "Registration" stay as they were.
1463
+ let mut seed = old_seed();
1464
+ seed["Studio::RegistrationNote#n1"] = serde_json::json!({"text": "hi"});
1465
+ let mut expected = seed.clone();
1466
+ expected["Studio::Registration#r1"]["status"] = serde_json::json!("active");
1467
+ expected["Studio::Registration#r2"]["status"] = serde_json::json!("active");
1468
+ fill_lifecycle_defaults(&mut seed, &[registration_default()]);
1469
+ assert_eq!(seed, expected);
1470
+ }
1471
+
1472
+ #[test]
1473
+ fn filling_twice_changes_nothing_the_second_time() {
1474
+ let mut seed = old_seed();
1475
+ fill_lifecycle_defaults(&mut seed, &[registration_default()]);
1476
+ let once = seed.clone();
1477
+ assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 0);
1478
+ assert_eq!(seed, once);
1479
+ }
1480
+
1481
+ #[test]
1482
+ fn a_seed_that_is_not_an_object_or_holds_a_non_object_instance_is_left_alone() {
1483
+ let mut empty = serde_json::json!({});
1484
+ assert_eq!(fill_lifecycle_defaults(&mut empty, &[registration_default()]), 0);
1485
+ let mut not_a_seed = serde_json::json!([1, 2]);
1486
+ assert_eq!(fill_lifecycle_defaults(&mut not_a_seed, &[registration_default()]), 0);
1487
+ let mut odd = serde_json::json!({"Studio::Registration#r1": "scalar"});
1488
+ assert_eq!(fill_lifecycle_defaults(&mut odd, &[registration_default()]), 0);
1489
+ assert_eq!(odd["Studio::Registration#r1"], "scalar");
1490
+ }
1491
+
1492
+ #[test]
1493
+ fn lifecycle_defaults_are_read_from_the_irs_aggregates() {
1494
+ let ir = serde_json::json!({
1495
+ "name": "Studio",
1496
+ "aggregates": [
1497
+ {"name": "Event", "lifecycle": {"field": "status", "default": "open", "transitions": []}},
1498
+ {"name": "Registration", "lifecycle": {"field": "status", "default": "active", "transitions": []}},
1499
+ {"name": "Note", "lifecycle": null},
1500
+ {"name": "Bare"}
1501
+ ]
1502
+ });
1503
+ assert_eq!(
1504
+ lifecycle_defaults(&ir),
1505
+ vec![
1506
+ LifecycleDefault { key_prefix: "Studio::Event#".to_string(), field: "status".to_string(), default: "open".to_string() },
1507
+ registration_default(),
1508
+ ]
1509
+ );
1510
+ assert!(lifecycle_defaults(&serde_json::json!({"name": "Studio"})).is_empty());
1511
+ }
1512
+
1513
+ /// The snapshot a live host wrote under the old shape holds registrations
1514
+ /// with no `status`; minting the new era fills them inside the mint's own transaction.
1515
+ #[tokio::test]
1516
+ async fn minting_an_era_fills_lifecycle_defaults_into_the_stored_snapshot() {
1517
+ let client = own_scratch_db("hecks_host_mint_snapshot_fill").await;
1518
+ journal::ensure_schema(&client).await.expect("ensure_schema");
1519
+ ensure_base(&client, "Studio").await.expect("ensure_base");
1520
+
1521
+ fn shape(with_status: bool) -> Value {
1522
+ let mut aggregate = serde_json::json!({
1523
+ "name": "Registration",
1524
+ "identified_by": ["registration_id"],
1525
+ "attributes": [{"name": "registration_id", "type": "Id", "list": false}],
1526
+ "value_objects": [{"name": "Id", "attributes": [{"name": "value", "type": "String", "list": false}]}],
1527
+ "entities": []
1528
+ });
1529
+ if with_status {
1530
+ aggregate["lifecycle"] = serde_json::json!({"field": "status", "default": "active", "transitions": []});
1531
+ }
1532
+ serde_json::json!({"name": "Studio", "aggregates": [aggregate]})
1533
+ }
1534
+ let (v1_ir, v2_ir) = (shape(false), shape(true));
1535
+ let aggregate = Aggregate { name: "Registration".to_string(), storage_name: "registration".to_string() };
1536
+ hold_first(&client, "Studio", "v1 source", &v1_ir, &[aggregate.clone()], None).await.expect("hold_first");
1537
+ journal::save_snapshot(&client, 7, &old_seed()).await.expect("save_snapshot");
1538
+
1539
+ let to_label = storage_shape::mint_label(&v2_ir);
1540
+ let edge = Edge {
1541
+ from: storage_shape::mint_label(&v1_ir),
1542
+ to: to_label.clone(),
1543
+ aggregates: vec![EdgeAggregate {
1544
+ name: "Registration".to_string(),
1545
+ was: None,
1546
+ has_compute_or_rekey: false,
1547
+ compiled_state_expression: "state || jsonb_build_object('status', 'active')".to_string(),
1548
+ compiled_id_expression: None,
1549
+ }],
1550
+ };
1551
+ let defaults = lifecycle_defaults(&v2_ir);
1552
+ assert_eq!(defaults.len(), 1);
1553
+ // The defaults name "Studio::Registration#", matching the seed's keys.
1554
+ mint_era(&client, "Studio", 2, &storage_shape::mint_hash(&v2_ir), &to_label, "v2 source", &[aggregate], &[&edge], None, &defaults)
1555
+ .await
1556
+ .expect("mint_era");
1557
+
1558
+ let snapshot = journal::load_snapshot(&client).await.unwrap().expect("the snapshot row is still there");
1559
+ assert_eq!(snapshot.ordinal, 7, "the snapshot's ordinal is not touched, only its instances");
1560
+ assert_eq!(snapshot.seed["Studio::Registration#r1"]["status"], "active");
1561
+ assert_eq!(snapshot.seed["Studio::Registration#r2"]["status"], "active");
1562
+ assert!(snapshot.seed["Studio::Event#yoga"]["status"] == "open" && snapshot.seed["Payments::Payment#r1"]["status"] == "succeeded");
1563
+ }
1564
+
1565
+ #[tokio::test]
1566
+ async fn filling_a_database_with_no_snapshot_table_or_row_is_a_no_op() {
1567
+ let client = own_scratch_db("hecks_host_mint_snapshot_fill_absent").await;
1568
+ assert_eq!(fill_snapshot_lifecycle_defaults(&client, "Studio", &[registration_default()]).await.unwrap(), 0, "no table");
1569
+ journal::ensure_schema(&client).await.expect("ensure_schema");
1570
+ assert_eq!(fill_snapshot_lifecycle_defaults(&client, "Studio", &[registration_default()]).await.unwrap(), 0, "no row");
1571
+ }
1572
+
1573
+ /// The compiled expression a backfill rule writes for one path, wrapped around `inner`.
1574
+ fn backfill_expression(inner: &str, path: &[&str], default_json: &str) -> String {
1575
+ let array = format!("ARRAY[{}]::text[]", path.iter().map(|p| format!("'{p}'")).collect::<Vec<_>>().join(", "));
1576
+ let label = path.join(".");
1577
+ format!(
1578
+ "(SELECT CASE WHEN (hecks_tr_extract(__s, {array})).present THEN __s ELSE hecks_tr_insert(__s, {array}, '{default_json}'::jsonb, 'backfill {label}') END FROM (SELECT ({inner}) AS __s) __outer)"
1579
+ )
1580
+ }
1581
+
1582
+ /// Every row a chain statement yields, as sorted text.
1583
+ async fn raw_rows(client: &tokio_postgres::Client, sql: &str) -> Vec<String> {
1584
+ let mut rows: Vec<String> = client
1585
+ .query(&format!("SELECT ordinal::text || '|' || aggregate_id || '|' || operation || '|' || coalesce(state::text, 'null') FROM ({sql}) c"), &[])
1586
+ .await
1587
+ .expect("chain query")
1588
+ .iter()
1589
+ .map(|r| r.get::<_, String>(0))
1590
+ .collect();
1591
+ rows.sort();
1592
+ rows
1593
+ }
1594
+
1595
+ fn edge_of(from: &str, to: &str, aggregate: &str, expression: &str) -> Edge {
1596
+ Edge {
1597
+ from: from.to_string(),
1598
+ to: to.to_string(),
1599
+ aggregates: vec![EdgeAggregate {
1600
+ name: aggregate.to_string(),
1601
+ was: None,
1602
+ has_compute_or_rekey: false,
1603
+ compiled_state_expression: expression.to_string(),
1604
+ compiled_id_expression: None,
1605
+ }],
1606
+ }
1607
+ }
1608
+
1609
+ /// The statement text carries the fence on the tail and on every edge, for any
1610
+ /// chain length: that is what keeps the planner from copying an edge's
1611
+ /// expression into each read of `state`.
1612
+ #[test]
1613
+ fn every_cte_of_the_chain_is_materialized() {
1614
+ let watermarks = std::collections::HashMap::new();
1615
+ let edges: Vec<Edge> = (1..=6).map(|n| edge_of(&format!("e{n}"), &format!("e{}", n + 1), "Registration", "state")).collect();
1616
+ for n in 1..=6usize {
1617
+ let chain: Vec<&Edge> = edges[..n].iter().collect();
1618
+ let sql = chain_sql("Chain", "Registration", (n + 1) as i32, &chain, &watermarks);
1619
+ assert!(sql.starts_with("WITH tail AS MATERIALIZED ("), "{sql}");
1620
+ assert_eq!(sql.matches("AS MATERIALIZED (").count(), n + 1, "the tail and each of the {n} edges: {sql}");
1621
+ for edge in 1..=n {
1622
+ assert!(sql.contains(&format!("edge_{edge} AS MATERIALIZED (")), "edge {edge}: {sql}");
1623
+ }
1624
+ }
1625
+ }
1626
+
1627
+ /// The plan stays linear in the chain length. Counting the plan's lines is
1628
+ /// deterministic (a timing assertion would flake): a fenced 12-edge chain
1629
+ /// stays small, and an unfenced 5-edge chain is already many times its size.
1630
+ #[tokio::test]
1631
+ async fn the_chain_plan_grows_with_the_edges_not_exponentially() {
1632
+ let client = own_scratch_db("hecks_host_mint_chain_plan_size").await;
1633
+ journal::ensure_schema(&client).await.expect("ensure_schema");
1634
+ let aggregates = vec![Aggregate { name: "Registration".to_string(), storage_name: "registration".to_string() }];
1635
+ hold_first(&client, "Plan", "era 1", &serde_json::json!({"name": "Plan", "aggregates": []}), &aggregates, None).await.expect("hold_first");
1636
+
1637
+ let mut heavy = "hecks_tr_drop(state, ARRAY['attendee', 'name']::text[])".to_string();
1638
+ for field in ["first_name", "last_name", "phone", "how_heard", "aim"] {
1639
+ heavy = backfill_expression(&heavy, &["attendee", field], "\"(none)\"");
1640
+ }
1641
+ let mut edges = vec![edge_of("e1", "e2", "Registration", &heavy)];
1642
+ for n in 2..=11 {
1643
+ edges.push(edge_of(&format!("e{n}"), &format!("e{}", n + 1), "Registration", "state"));
1644
+ }
1645
+ edges.push(edge_of("e12", "e13", "Registration", &backfill_expression("state", &["status"], "\"active\"")));
1646
+
1647
+ let watermarks = std::collections::HashMap::new();
1648
+ let plan_lines = |chain: &[&Edge], materialize: bool| {
1649
+ let sql = chain_sql_with("Plan", "Registration", (chain.len() + 1) as i32, chain, &watermarks, materialize);
1650
+ let client = &client;
1651
+ async move { client.query(&format!("EXPLAIN (COSTS OFF) {sql}"), &[]).await.expect("explain").len() }
1652
+ };
1653
+
1654
+ let all: Vec<&Edge> = edges.iter().collect();
1655
+ let fenced_12 = plan_lines(&all, true).await;
1656
+ let fenced_6 = plan_lines(&all[..6], true).await;
1657
+ assert!(fenced_12 < 2 * fenced_6.max(500), "12 fenced edges plan to {fenced_12} lines, 6 to {fenced_6}");
1658
+ let inlined_5 = plan_lines(&all[..5], false).await;
1659
+ let fenced_5 = plan_lines(&all[..5], true).await;
1660
+ assert!(inlined_5 > 10 * fenced_5, "unfenced 5 edges: {inlined_5} plan lines; fenced: {fenced_5}");
1661
+ }
1662
+
1663
+ /// Materializing each edge's CTE must not change one row of any chain: five
1664
+ /// eras of history, a delete and a re-saved id, compared as raw rows and as
1665
+ /// the latest-per-id result the audit and the head build read.
1666
+ #[tokio::test]
1667
+ async fn materializing_the_edge_ctes_returns_exactly_the_rows_the_inlined_chain_does() {
1668
+ let client = own_scratch_db("hecks_host_mint_chain_differential").await;
1669
+ journal::ensure_schema(&client).await.expect("ensure_schema");
1670
+ let domain = "Chain";
1671
+ let aggregates = vec![
1672
+ Aggregate { name: "Registration".to_string(), storage_name: "registration".to_string() },
1673
+ Aggregate { name: "Event".to_string(), storage_name: "event".to_string() },
1674
+ ];
1675
+ let ir = serde_json::json!({"name": domain, "aggregates": []});
1676
+ hold_first(&client, domain, "era 1", &ir, &aggregates, None).await.expect("hold_first");
1677
+
1678
+ // Edge 1 backfills four nested paths; edges 2 and 3 carry no rule (as
1679
+ // most of a real deployment's edges do); edge 4 backfills one more.
1680
+ let mut nested = "hecks_tr_drop(state, ARRAY['attendee', 'name']::text[])".to_string();
1681
+ for field in ["first_name", "last_name", "phone", "aim"] {
1682
+ nested = backfill_expression(&nested, &["attendee", field], "\"(none)\"");
1683
+ }
1684
+ let edges = vec![
1685
+ edge_of("e1", "e2", "Registration", &nested),
1686
+ edge_of("e2", "e3", "Event", "state"),
1687
+ edge_of("e3", "e4", "Event", "state"),
1688
+ edge_of("e4", "e5", "Registration", &backfill_expression("state", &["status"], "\"active\"")),
1689
+ ];
1690
+
1691
+ let journal_name = "hecks_journal_chain";
1692
+ let insert = |era: i32, aggregate: &str, id: &str, operation: &str, state: Value| {
1693
+ let client = &client;
1694
+ let (aggregate, id, operation) = (aggregate.to_string(), id.to_string(), operation.to_string());
1695
+ async move {
1696
+ client
1697
+ .execute(
1698
+ &format!("INSERT INTO {journal_name} (era, aggregate, aggregate_id, operation, state) VALUES ($1, $2, $3, $4, $5)"),
1699
+ &[&era, &aggregate, &id, &operation, &state],
1700
+ )
1701
+ .await
1702
+ .expect("journal insert");
1703
+ }
1704
+ };
1705
+ for era in 1..=5i32 {
1706
+ // era 1 carries the old attendee shape, later eras the new one
1707
+ let attendee = |n: &str| if era == 1 { serde_json::json!({"name": n, "email": "a@example.com"}) } else { serde_json::json!({"first_name": n, "email": "a@example.com"}) };
1708
+ for i in 0..6 {
1709
+ let id = format!("r{}", (era as usize + i) % 5);
1710
+ insert(era, "registration", &id, "save", serde_json::json!({"attendee": attendee(&format!("n{era}{i}"))})).await;
1711
+ }
1712
+ insert(era, "event", &format!("e{era}"), "save", serde_json::json!({"name": {"value": format!("event {era}")}})).await;
1713
+ insert(era, "event", "e1", "save", serde_json::json!({"name": {"value": "renamed"}})).await;
1714
+ if era == 3 {
1715
+ insert(era, "registration", "r0", "delete", Value::Null).await;
1716
+ }
1717
+ if era < 5 {
1718
+ let chain: Vec<&Edge> = edges[..era as usize].iter().collect();
1719
+ mint_era(&client, domain, era + 1, &"h".repeat(64), &format!("e{}", era + 1), "text", &aggregates, &chain, None, &[]).await.expect("mint_era");
1720
+ }
1721
+ }
1722
+
1723
+ let held = journal::held_eras(&client, domain).await.unwrap();
1724
+ let watermarks: std::collections::HashMap<i32, Option<i64>> = held.iter().map(|h| (h.ordinal, h.watermark)).collect();
1725
+ let mut compared = 0;
1726
+ for aggregate in &aggregates {
1727
+ for n in 1..=edges.len() {
1728
+ let chain: Vec<&Edge> = edges[..n].iter().collect();
1729
+ let era = (n + 1) as i32;
1730
+ let inlined = chain_sql_with(domain, &aggregate.name, era, &chain, &watermarks, false);
1731
+ let fenced = chain_sql_with(domain, &aggregate.name, era, &chain, &watermarks, true);
1732
+ assert_ne!(inlined, fenced, "the two forms really differ");
1733
+ assert!(fenced.contains("AS MATERIALIZED (") && !inlined.contains("MATERIALIZED"));
1734
+
1735
+ let (a, b) = (raw_rows(&client, &inlined).await, raw_rows(&client, &fenced).await);
1736
+ assert!(!a.is_empty(), "{} chain of {n}: the fixture yields rows", aggregate.name);
1737
+ assert_eq!(a, b, "{} chain of {n}: raw rows", aggregate.name);
1738
+ assert_eq!(
1739
+ latest_of(&client, &inlined).await.unwrap(),
1740
+ latest_of(&client, &fenced).await.unwrap(),
1741
+ "{} chain of {n}: latest per id",
1742
+ aggregate.name
1743
+ );
1744
+ compared += 1;
1745
+ }
1746
+ }
1747
+ assert_eq!(compared, 8);
1748
+ // and the fixture exercised what it claims to: a backfilled status reached the head
1749
+ let translated = translated_latest(&client, domain, "Registration", 5, &edges.iter().collect::<Vec<_>>(), &watermarks).await.unwrap();
1750
+ assert!(translated.values().all(|state| state["status"] == "active"), "{translated:?}");
1751
+ assert_eq!(translated.len(), 5, "five registrations were ever saved: {translated:?}");
1752
+ }
1753
+
1754
+ // Layer 2, proven live both directions: an edge whose declared rules agree
1755
+ // with the compiled SQL passes silently; one that disagrees (the exact bug
1756
+ // this audit exists to catch) refuses, naming the divergence.
1757
+ #[tokio::test]
1758
+ async fn audit_before_mint_passes_a_true_edge_and_refuses_a_lying_one() {
1759
+ let db = "rust_host_audit_test";
1760
+ let owner = "rust_host_audit_owner";
1761
+
1762
+ let admin = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await;
1763
+ let (admin_client, admin_connection) = admin.expect("connect to postgres as admin");
1764
+ tokio::spawn(async move {
1765
+ let _ = admin_connection.await;
1766
+ });
1767
+ let _ = admin_client.batch_execute(&format!("DROP DATABASE IF EXISTS {db} WITH (FORCE)")).await;
1768
+ admin_client.batch_execute(&format!("CREATE DATABASE {db}")).await.expect("create scratch db");
1769
+ let _ = admin_client.batch_execute(&format!("DROP ROLE IF EXISTS {owner}")).await;
1770
+ admin_client.batch_execute(&format!("CREATE ROLE {owner} {}", crate::test_pg::login_clause())).await.expect("create owner role");
1771
+ admin_client.batch_execute(&format!("GRANT CONNECT ON DATABASE {db} TO {owner}")).await.expect("grant connect");
1772
+
1773
+ let grant = tokio_postgres::connect(&crate::test_pg::conninfo(&db), NoTls).await;
1774
+ let (grant_client, grant_connection) = grant.expect("connect to scratch db as superuser to grant schema rights");
1775
+ tokio::spawn(async move {
1776
+ let _ = grant_connection.await;
1777
+ });
1778
+ grant_client.batch_execute(&format!("GRANT USAGE, CREATE ON SCHEMA public TO {owner}")).await.expect("grant schema rights");
1779
+ grant_client.batch_execute(&format!("ALTER DATABASE {db} OWNER TO {owner}")).await.expect("make owner the db owner");
1780
+
1781
+ let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo_as(&db, &owner), NoTls).await.expect("connect as owner");
1782
+ tokio::spawn(async move {
1783
+ let _ = connection.await;
1784
+ });
1785
+
1786
+ fn shape(attribute_name: &str) -> Value {
1787
+ serde_json::json!({
1788
+ "name": "AuditTest",
1789
+ "aggregates": [{
1790
+ "name": "Widget",
1791
+ "identified_by": ["kind"],
1792
+ "attributes": [
1793
+ {"name": attribute_name, "type": "Money", "list": false},
1794
+ {"name": "kind", "type": "Kind", "list": false}
1795
+ ],
1796
+ "value_objects": [
1797
+ {"name": "Money", "attributes": [{"name": "cents", "type": "Integer", "list": false}]},
1798
+ {"name": "Kind", "attributes": [{"name": "label", "type": "String", "list": false}]}
1799
+ ],
1800
+ "entities": []
1801
+ }]
1802
+ })
1803
+ }
1804
+ let v1_ir = shape("cost");
1805
+ let v2_ir = shape("amount");
1806
+ let aggregate = Aggregate { name: "Widget".to_string(), storage_name: "widget".to_string() };
1807
+ let from_label = storage_shape::mint_label(&v1_ir);
1808
+ let to_label = storage_shape::mint_label(&v2_ir);
1809
+
1810
+ async fn seed_era_one(client: &tokio_postgres::Client, domain: &str, v1_ir: &Value, aggregate: &Aggregate) {
1811
+ hold_first(client, domain, "v1 source text", v1_ir, std::slice::from_ref(aggregate), None).await.expect("hold_first");
1812
+ let config = journal::LineageConfig { domain: domain.to_string(), era: Some(1), mirrored: None };
1813
+ journal::append_lineage_mutation(
1814
+ client,
1815
+ &config,
1816
+ &journal::Mutation { aggregate: "Widget", id: "w1", operation: "save", state: &serde_json::json!({"cost": {"cents": 100}, "kind": {"label": "w1"}}) },
1817
+ )
1818
+ .await
1819
+ .expect("write w1 under era 1");
1820
+ }
1821
+
1822
+ // The honest edge: declared renames genuinely match the compiled SQL —
1823
+ // audit_before_mint must find nothing wrong.
1824
+ let honest_domain = "AuditPass";
1825
+ seed_era_one(&client, honest_domain, &v1_ir, &aggregate).await;
1826
+ let honest_edge = Edge {
1827
+ from: from_label.clone(),
1828
+ to: to_label.clone(),
1829
+ aggregates: vec![EdgeAggregate {
1830
+ name: "Widget".to_string(),
1831
+ was: None,
1832
+ has_compute_or_rekey: false,
1833
+ compiled_state_expression: "hecks_tr_rename(state, 'cost', 'amount')".to_string(),
1834
+ compiled_id_expression: None,
1835
+ }],
1836
+ };
1837
+ let honest_raw_edges = vec![serde_json::json!({
1838
+ "domain": honest_domain, "from": from_label, "to": to_label,
1839
+ "aggregates": [{"name": "Widget", "renames": {"cost": "amount"}}]
1840
+ })];
1841
+ let watermarks: std::collections::HashMap<i32, Option<i64>> = [(1, None)].into_iter().collect();
1842
+ audit_before_mint(&client, honest_domain, &v2_ir, &[aggregate.clone()], 2, &[&honest_edge], &honest_raw_edges, &watermarks)
1843
+ .await
1844
+ .expect("an honest edge's audit must pass silently");
1845
+
1846
+ // The lying edge: declared `renames:` claims a different destination
1847
+ // than the compiled SQL produces — the exact bug this audit exists to
1848
+ // catch. Must refuse, naming the divergence, before anything mints.
1849
+ let lying_domain = "AuditFail";
1850
+ seed_era_one(&client, lying_domain, &v1_ir, &aggregate).await;
1851
+ let lying_edge = honest_edge.clone();
1852
+ let lying_raw_edges = vec![serde_json::json!({
1853
+ "domain": lying_domain, "from": from_label, "to": to_label,
1854
+ // Claims `cost` becomes `price` -- the compiled SQL above
1855
+ // still renames it to `amount`. A real divergence.
1856
+ "aggregates": [{"name": "Widget", "renames": {"cost": "price"}}]
1857
+ })];
1858
+ let error = audit_before_mint(&client, lying_domain, &v2_ir, &[aggregate], 2, &[&lying_edge], &lying_raw_edges, &watermarks)
1859
+ .await
1860
+ .expect_err("a declared rename that disagrees with the compiled SQL must refuse");
1861
+ let message = format!("{error:#}");
1862
+ assert!(message.contains("the audit refused"), "{message}");
1863
+ assert!(message.contains("diverges from the reference transform"), "{message}");
1864
+ }
1865
+ }