hecks 2.9.0 → 3.0.1

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 +13 -4
  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 +15 -8
  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 +15 -5
  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,1443 @@
1
+ //! Ties the journal and the sandbox together: replays history, then the
2
+ //! new step, persisting it only if the module accepts it.
3
+
4
+ use crate::journal;
5
+ use crate::journal::LineageConfig;
6
+ use crate::lambda_client::{self, LambdaInvoker};
7
+ use crate::wasm_runner;
8
+ use std::path::Path;
9
+ use tokio::sync::Mutex;
10
+ use tokio_postgres::Client;
11
+
12
+ pub struct Outcome {
13
+ pub result: serde_json::Value,
14
+ pub accepted: bool,
15
+ }
16
+
17
+ // Distinguishes a module refusal from a kernel failure to run at all.
18
+ #[derive(Debug, PartialEq)]
19
+ enum KernelAnswer {
20
+ Accepted,
21
+ Refused,
22
+ Failed(String),
23
+ }
24
+
25
+ // A run that never started has no `refusals` array at all, so absence of
26
+ // refusals doesn't by itself mean the run succeeded.
27
+ fn classify(result: &serde_json::Value) -> KernelAnswer {
28
+ if let Some(error) = result.get("error") {
29
+ return KernelAnswer::Failed(error.as_str().map(str::to_string).unwrap_or_else(|| error.to_string()));
30
+ }
31
+ let refused = result.get("refusals").and_then(|r| r.as_array()).is_some_and(|r| !r.is_empty());
32
+ if refused {
33
+ KernelAnswer::Refused
34
+ } else {
35
+ KernelAnswer::Accepted
36
+ }
37
+ }
38
+
39
+ // A kernel failure becomes `Err`, not a result with no instances read as
40
+ // an empty world.
41
+ fn parse_kernel_output(output: &str) -> anyhow::Result<serde_json::Value> {
42
+ let result: serde_json::Value = serde_json::from_str(output)?;
43
+ match classify(&result) {
44
+ KernelAnswer::Failed(message) => anyhow::bail!("the kernel failed before it could run the command: {message}"),
45
+ KernelAnswer::Accepted | KernelAnswer::Refused => Ok(result),
46
+ }
47
+ }
48
+
49
+ // Kept as one opaque object under the journal's `args` column; the kernel
50
+ // unwraps `to`/`with` itself, so this needs no journal schema migration.
51
+ pub fn routed_invocation(to: serde_json::Value, facts: serde_json::Value) -> anyhow::Result<serde_json::Value> {
52
+ if !facts.is_object() {
53
+ anyhow::bail!("with must be an object of command facts");
54
+ }
55
+ Ok(serde_json::json!({ "to": to, "with": facts }))
56
+ }
57
+
58
+ // No `to` route: used only when a command creates a new aggregate, so
59
+ // there is no receiver yet to route to.
60
+ pub fn facts_invocation(facts: serde_json::Value) -> anyhow::Result<serde_json::Value> {
61
+ if !facts.is_object() {
62
+ anyhow::bail!("with must be an object of command facts");
63
+ }
64
+ Ok(serde_json::json!({ "with": facts }))
65
+ }
66
+
67
+ // Route-aware entry point; `handle` below stays the compatibility path
68
+ // for callers still sending the older mixed args shape.
69
+ #[allow(clippy::too_many_arguments)]
70
+ pub async fn handle_routed(
71
+ client: &Mutex<Client>,
72
+ wasm_path: &Path,
73
+ verb: &str,
74
+ to: serde_json::Value,
75
+ facts: serde_json::Value,
76
+ role: Option<&str>,
77
+ config: &LineageConfig,
78
+ invoker: &dyn LambdaInvoker,
79
+ ) -> anyhow::Result<Outcome> {
80
+ handle(client, wasm_path, verb, routed_invocation(to, facts)?, role, config, invoker).await
81
+ }
82
+
83
+ // Facts-only counterpart to `handle_routed`.
84
+ #[allow(clippy::too_many_arguments)]
85
+ pub async fn handle_facts(
86
+ client: &Mutex<Client>,
87
+ wasm_path: &Path,
88
+ verb: &str,
89
+ facts: serde_json::Value,
90
+ role: Option<&str>,
91
+ config: &LineageConfig,
92
+ invoker: &dyn LambdaInvoker,
93
+ ) -> anyhow::Result<Outcome> {
94
+ handle(client, wasm_path, verb, facts_invocation(facts)?, role, config, invoker).await
95
+ }
96
+
97
+ // A Mutex, not a bare Arc<Client>: `handle` needs `Client::transaction`,
98
+ // which takes `&mut Client`. Locking also means concurrent callers on
99
+ // this connection can't interleave statements.
100
+ //
101
+ // `config` is required, not optional: `main.rs` already refused to boot
102
+ // unless the configured domain/era is provisioned and current, so by the
103
+ // time `handle` runs that schema is guaranteed to exist.
104
+ //
105
+ // `role` is optional, matching `Adapters::Lambda::Client#dispatch`'s own
106
+ // `role: nil` default: a step with no role looks the same on the wire
107
+ // whether or not a caller is bound.
108
+ pub async fn handle(
109
+ client: &Mutex<Client>,
110
+ wasm_path: &Path,
111
+ verb: &str,
112
+ args: serde_json::Value,
113
+ role: Option<&str>,
114
+ config: &LineageConfig,
115
+ invoker: &dyn LambdaInvoker,
116
+ ) -> anyhow::Result<Outcome> {
117
+ let mut guard = client.lock().await;
118
+ let txn = guard.transaction().await?;
119
+
120
+ // Locks the whole read-then-append sequence, not just the INSERT:
121
+ // two concurrent invocations could otherwise both rehydrate against
122
+ // the same prior steps and both pass a uniqueness check the domain
123
+ // thinks it's enforcing. Domain-scoped so unrelated domains sharing
124
+ // one Postgres instance don't serialize against each other.
125
+ txn.execute(
126
+ "SELECT pg_advisory_xact_lock(hashtext('hecks_lambda_journal.' || $1::text))",
127
+ &[&config.domain],
128
+ )
129
+ .await?;
130
+
131
+ // Reads the last snapshot plus only the journal rows after it,
132
+ // instead of replaying the whole history every time. `None` covers
133
+ // a brand-new domain and one with pre-snapshot history alike; both
134
+ // fall back to one full replay, which then leaves a snapshot behind.
135
+ let snapshot = journal::load_snapshot(&txn).await?;
136
+ // `sagas_backfilled` is a latch, not a live check of whether
137
+ // `hecks_lambda_sagas` is empty (empty is the ordinary state, not a
138
+ // signal). Only matters for a domain with real history predating
139
+ // saga durability, where the snapshot cache would otherwise never
140
+ // re-derive sagas from the journal.
141
+ let needs_saga_backfill = snapshot.as_ref().is_some_and(|s| !s.sagas_backfilled);
142
+ let mut steps = match &snapshot {
143
+ Some(s) if !needs_saga_backfill => journal::load_steps_after(&txn, s.ordinal).await?,
144
+ _ => journal::load_steps(&txn).await?,
145
+ };
146
+ // Role goes only on this outermost step; replayed history already
147
+ // carries whatever role it was dispatched with. Built as a mutable
148
+ // step, not inlined into the `json!` literal, so a roleless call
149
+ // still omits the `"role"` key entirely rather than sending null.
150
+ let mut step = serde_json::json!({ "verb": verb, "args": args.clone() });
151
+ if let Some(role) = role {
152
+ step["role"] = serde_json::Value::String(role.to_string());
153
+ }
154
+ // Stamped only on this live step; replayed history already carries
155
+ // its own real timestamp, and re-stamping today's time onto a
156
+ // replay would be wrong, not just redundant.
157
+ step["occurred_at"] = serde_json::Value::String(crate::auth::httpdate_now());
158
+ steps.push(step);
159
+ // Must match `steps`' own choice above: a full replay needs an empty
160
+ // seed, or the kernel replays into a world that already has every
161
+ // record being created again, refusing each as a false AlreadyExists.
162
+ let seed = if needs_saga_backfill {
163
+ serde_json::json!({})
164
+ } else {
165
+ snapshot.as_ref().map(|s| s.seed.clone()).unwrap_or_else(|| serde_json::json!({}))
166
+ };
167
+
168
+ // Read inside the same advisory-locked transaction: the lock covers
169
+ // saga state too, not just the aggregate journal/snapshot.
170
+ let saga_rows = journal::load_sagas(&txn).await?;
171
+ let sagas_seed = serde_json::json!(saga_rows
172
+ .iter()
173
+ .map(|r| serde_json::json!({
174
+ "process_manager": r.process_manager,
175
+ "correlation": r.correlation,
176
+ "state": r.state,
177
+ "memory": r.memory,
178
+ "completed_compensations": r.completed_compensations,
179
+ }))
180
+ .collect::<Vec<_>>());
181
+
182
+ let input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed }).to_string();
183
+ // `wasm_runner::run` is sync, and wasmtime-wasi's sync bridge spins up
184
+ // its own tokio runtime internally — fatal on a thread already
185
+ // driving one. `spawn_blocking` moves it off this async runtime.
186
+ let owned_wasm_path = wasm_path.to_path_buf();
187
+ let output =
188
+ tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
189
+ // A kernel failure returns here, before anything is written: dropping `txn`
190
+ // rolls it back, so no journal row, snapshot, saga or mutation is saved.
191
+ let mut result = parse_kernel_output(&output)?;
192
+
193
+ // See journal.rs's own header: every prior step in this replay
194
+ // already succeeded once, deterministically, so the only step that
195
+ // can legitimately show up in `refusals` is the new one just
196
+ // appended last.
197
+ let accepted = classify(&result) == KernelAnswer::Accepted;
198
+
199
+ if accepted {
200
+ let ordinal = journal::append(&txn, verb, &args).await?;
201
+
202
+ // The kernel's "instances" output is already the exact seed
203
+ // shape, so nothing here re-derives or filters it.
204
+ let new_seed = result.get("instances").cloned().unwrap_or_else(|| serde_json::json!({}));
205
+ journal::save_snapshot(&txn, ordinal, &new_seed).await?;
206
+
207
+ // Persisted in the same transaction, so a saga checkpoint can
208
+ // never commit independently of the command that produced it.
209
+ // Reconciled against `saga_rows` (pre-run state): present now
210
+ // is upserted, present before but absent now is deleted.
211
+ if let Some(saga_snapshot) = result.get("saga_snapshot").and_then(|v| v.as_array()) {
212
+ let mut still_present: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
213
+ for entry in saga_snapshot {
214
+ let process_manager = entry
215
+ .get("process_manager")
216
+ .and_then(|v| v.as_str())
217
+ .ok_or_else(|| anyhow::anyhow!("saga_snapshot entry missing \"process_manager\": {entry}"))?;
218
+ let correlation = entry
219
+ .get("correlation")
220
+ .and_then(|v| v.as_str())
221
+ .ok_or_else(|| anyhow::anyhow!("saga_snapshot entry missing \"correlation\": {entry}"))?;
222
+ let state = entry
223
+ .get("state")
224
+ .and_then(|v| v.as_str())
225
+ .ok_or_else(|| anyhow::anyhow!("saga_snapshot entry missing \"state\": {entry}"))?;
226
+ let memory = entry.get("memory").cloned().unwrap_or_else(|| serde_json::json!({}));
227
+ let completed_compensations = entry.get("completed_compensations").cloned().unwrap_or_else(|| serde_json::json!([]));
228
+
229
+ journal::save_saga(&txn, process_manager, correlation, state, &memory, &completed_compensations).await?;
230
+ still_present.insert((process_manager.to_string(), correlation.to_string()));
231
+ }
232
+ for row in &saga_rows {
233
+ let key = (row.process_manager.clone(), row.correlation.clone());
234
+ if !still_present.contains(&key) {
235
+ journal::delete_saga(&txn, &row.process_manager, &row.correlation).await?;
236
+ }
237
+ }
238
+ }
239
+
240
+ // One entry per step in `steps`, so the last entry is this
241
+ // call's own new step. Skipped when `config.era` is `None`: a
242
+ // domain with nothing lineage-capable bound never provisions a
243
+ // head-snapshot table to write into (ADR 0034).
244
+ let step_mutations = if config.era.is_some() {
245
+ result
246
+ .get("mutations")
247
+ .and_then(|m| m.as_array())
248
+ .and_then(|steps| steps.last())
249
+ .and_then(|last| last.as_array())
250
+ .cloned()
251
+ .unwrap_or_default()
252
+ } else {
253
+ Vec::new()
254
+ };
255
+
256
+ for mutation in &step_mutations {
257
+ let aggregate = mutation
258
+ .get("aggregate")
259
+ .and_then(|v| v.as_str())
260
+ .ok_or_else(|| anyhow::anyhow!("mutation record missing \"aggregate\": {mutation}"))?;
261
+ // `era.is_some()` means the lineage subsystem exists, not that
262
+ // this aggregate has a mirror: `mint` only provisions head
263
+ // snapshots for the capable set, so mirroring anything else
264
+ // would upsert into a relation nobody created.
265
+ if !config.mirrors(aggregate) {
266
+ continue;
267
+ }
268
+ let id = mutation
269
+ .get("id")
270
+ .and_then(|v| v.as_str())
271
+ .ok_or_else(|| anyhow::anyhow!("mutation record missing \"id\": {mutation}"))?;
272
+ let operation = mutation
273
+ .get("operation")
274
+ .and_then(|v| v.as_str())
275
+ .ok_or_else(|| anyhow::anyhow!("mutation record missing \"operation\": {mutation}"))?;
276
+ let state = mutation
277
+ .get("state")
278
+ .ok_or_else(|| anyhow::anyhow!("mutation record missing \"state\": {mutation}"))?;
279
+
280
+ journal::append_lineage_mutation(
281
+ &txn,
282
+ config,
283
+ &journal::Mutation { aggregate, id, operation, state },
284
+ )
285
+ .await?;
286
+ }
287
+ }
288
+
289
+ // Only this step's own matches, same reason as `step_mutations`
290
+ // above: replaying a prior step's reaction would re-invoke a sibling
291
+ // Lambda on every later replay. Captured before commit, delivered after.
292
+ let pending_cross_domain = result
293
+ .get("cross_domain_reactions")
294
+ .and_then(|r| r.as_array())
295
+ .and_then(|steps| steps.last())
296
+ .and_then(|last| last.as_array())
297
+ .cloned()
298
+ .unwrap_or_default();
299
+
300
+ // Commits (and releases the advisory lock) whether or not anything
301
+ // was appended — a refused command still needs the lock released
302
+ // for the next invocation to proceed.
303
+ txn.commit().await?;
304
+
305
+ // Delivered after commit, deliberately: the local command already
306
+ // succeeded and is durable, so a cross-Lambda notification is
307
+ // best-effort, not a precondition of it. A fault that survives every
308
+ // retry still fails this call via `?`, but never unwinds an
309
+ // already-committed transaction.
310
+ // Every reaction gets its own attempt even after an earlier fails:
311
+ // returning early would silently drop the rest, undelivered and
312
+ // never dead-lettered (`SafeDepositBox.Surrender` fires two from one
313
+ // command). `first_failure` remembers only the first error without
314
+ // cutting the loop short.
315
+ let mut cross_domain_deliveries = Vec::new();
316
+ let mut first_failure: Option<anyhow::Error> = None;
317
+ for reaction in &pending_cross_domain {
318
+ match lambda_client::deliver_with_retry(invoker, reaction).await {
319
+ Ok(record) => {
320
+ if let Some(response) = result.as_object_mut() {
321
+ if let Some(reactions) = response.get_mut("reactions").and_then(|r| r.as_array_mut()) {
322
+ reactions.push(serde_json::json!({
323
+ "policy": record.policy, "on": reaction.get("on").and_then(|v| v.as_str()).unwrap_or_default(),
324
+ "trigger": record.target_verb, "delivered": record.delivered, "reason": record.reason,
325
+ }));
326
+ }
327
+ }
328
+ cross_domain_deliveries.push(record.to_json());
329
+ }
330
+ Err(failure) => {
331
+ let error_text = format!("{:#}", failure.error);
332
+ crate::log::error("cross_domain_delivery_failed", serde_json::json!({
333
+ "policy": failure.policy, "target_domain": failure.target_domain,
334
+ "target_verb": failure.target_verb, "attempts": failure.attempts, "error": error_text,
335
+ }));
336
+ journal::record_dead_letter(
337
+ &*guard,
338
+ &failure.policy,
339
+ &failure.target_domain,
340
+ &failure.target_verb,
341
+ &failure.payload,
342
+ &error_text,
343
+ failure.attempts as i32,
344
+ )
345
+ .await?;
346
+ if first_failure.is_none() {
347
+ first_failure = Some(failure.error);
348
+ }
349
+ }
350
+ }
351
+ }
352
+ if let Some(error) = first_failure {
353
+ return Err(error);
354
+ }
355
+ if let Some(response) = result.as_object_mut() {
356
+ response.insert("cross_domain_deliveries".to_string(), serde_json::Value::Array(cross_domain_deliveries));
357
+ }
358
+
359
+ log_command(verb, role, accepted, &result);
360
+ Ok(Outcome { result, accepted })
361
+ }
362
+
363
+ // One line per dispatched command: what was asked (verb, role — never
364
+ // the facts) and what the domain answered.
365
+ fn log_command(verb: &str, role: Option<&str>, accepted: bool, result: &serde_json::Value) {
366
+ let fields = command_fields(verb, role, accepted, result);
367
+ if accepted {
368
+ crate::log::info("command", fields);
369
+ } else {
370
+ crate::log::error("command", fields);
371
+ }
372
+ }
373
+
374
+ fn command_fields(verb: &str, role: Option<&str>, accepted: bool, result: &serde_json::Value) -> serde_json::Value {
375
+ let events: Vec<serde_json::Value> = result
376
+ .get("events")
377
+ .and_then(|v| v.as_array())
378
+ .map(|items| items.iter().filter_map(|item| item.get("name").cloned()).collect())
379
+ .unwrap_or_default();
380
+ serde_json::json!({
381
+ "verb": verb, "role": role, "accepted": accepted, "events": events,
382
+ "refusals": result.get("refusals").cloned().unwrap_or_else(|| serde_json::json!([])),
383
+ })
384
+ }
385
+
386
+ // Read-only: the same seed path `handle` uses, minus the new step and
387
+ // any write. No advisory lock needed since nothing here writes.
388
+ // Returns the whole kernel result verbatim, not just `instances`, so a
389
+ // caller can tell an empty `refusals` from one that was never checked.
390
+ pub async fn read(client: &Mutex<Client>, wasm_path: &Path) -> anyhow::Result<serde_json::Value> {
391
+ let guard = client.lock().await;
392
+ let snapshot = journal::load_snapshot(&*guard).await?;
393
+
394
+ if let Some(s) = &snapshot {
395
+ let steps_since = journal::load_steps_after(&*guard, s.ordinal).await?;
396
+ drop(guard);
397
+
398
+ if steps_since.is_empty() {
399
+ // No wasm invocation needed: the snapshot's seed already is
400
+ // this domain's current "instances" output. Empty
401
+ // events/refusals is correct too — a read reports current
402
+ // state, never event/refusal history.
403
+ return Ok(serde_json::json!({ "instances": s.seed, "events": [], "refusals": [] }));
404
+ }
405
+
406
+ // Self-healing: if steps ever go unaccounted for after the last
407
+ // snapshot, seeding from it and replaying just the gap is
408
+ // still correct and cheap.
409
+ let input = serde_json::json!({ "seed": &s.seed, "steps": steps_since }).to_string();
410
+ let owned_wasm_path = wasm_path.to_path_buf();
411
+ let output =
412
+ tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
413
+ return parse_kernel_output(&output);
414
+ }
415
+
416
+ // No snapshot: either a brand-new domain, or one with history from
417
+ // before this snapshot cache existed — either way, one full replay
418
+ // catches it up.
419
+ let steps = journal::load_steps(&*guard).await?;
420
+ drop(guard);
421
+ let input = serde_json::json!({ "steps": steps }).to_string();
422
+ let owned_wasm_path = wasm_path.to_path_buf();
423
+ let output =
424
+ tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
425
+ parse_kernel_output(&output)
426
+ }
427
+
428
+ // A declared query, answered against current state. Seeds from `read`
429
+ // rather than replaying history itself, so it sees exactly the state a
430
+ // read would report and pays at most one wasm invocation.
431
+ pub async fn query(
432
+ client: &Mutex<Client>,
433
+ wasm_path: &Path,
434
+ question: &str,
435
+ args: serde_json::Value,
436
+ ) -> anyhow::Result<serde_json::Value> {
437
+ let state = read(client, wasm_path).await?;
438
+ let seed = state.get("instances").cloned().unwrap_or_else(|| serde_json::json!({}));
439
+ let input = serde_json::json!({ "seed": seed, "steps": [{ "query": question, "args": args }] }).to_string();
440
+ let owned_wasm_path = wasm_path.to_path_buf();
441
+ let output = tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
442
+ parse_kernel_output(&output)
443
+ }
444
+
445
+ #[cfg(test)]
446
+ pub(crate) mod tests {
447
+
448
+ #[test]
449
+ fn a_command_log_line_names_the_verb_events_and_refusals_but_never_the_facts() {
450
+ let result = serde_json::json!({
451
+ "events": [{"name": "Registered", "data": {"email": "a@b.c"}}],
452
+ "refusals": [{"verb": "X.Y", "error": "already exists", "kind": "AlreadyExists"}],
453
+ });
454
+ let fields = command_fields("Register", Some("admin"), false, &result);
455
+ assert_eq!(fields["verb"], "Register");
456
+ assert_eq!(fields["role"], "admin");
457
+ assert_eq!(fields["accepted"], false);
458
+ assert_eq!(fields["events"], serde_json::json!(["Registered"]));
459
+ assert_eq!(fields["refusals"][0]["kind"], "AlreadyExists");
460
+ assert!(!fields.to_string().contains("a@b.c"));
461
+ }
462
+ use super::*;
463
+ use tokio_postgres::NoTls;
464
+
465
+ // A throwaway Postgres database per test, uniquely named so
466
+ // `cargo test`'s default parallelism doesn't race two tests against
467
+ // the same journal table.
468
+ pub(crate) async fn scratch_db(name: &str) -> Mutex<Client> {
469
+ let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
470
+ .await
471
+ .expect("connect to postgres");
472
+ tokio::spawn(async move {
473
+ let _ = conn.await;
474
+ });
475
+ admin
476
+ .batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
477
+ .await
478
+ .unwrap();
479
+ admin
480
+ .batch_execute(&format!("CREATE DATABASE {name}"))
481
+ .await
482
+ .unwrap();
483
+
484
+ let (client, conn) =
485
+ tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls)
486
+ .await
487
+ .expect("connect to scratch db");
488
+ tokio::spawn(async move {
489
+ let _ = conn.await;
490
+ });
491
+ journal::ensure_schema(&client).await.unwrap();
492
+ Mutex::new(client)
493
+ }
494
+
495
+ // Not Ruby's real provisioning — just enough structure for
496
+ // `journal::current_era` and `append_lineage_mutation` to succeed,
497
+ // without reproducing Ruby's full DDL.
498
+ pub(crate) async fn provision_lineage(client: &Client, domain: &str, era: i32, aggregate_storage_names: &[&str]) {
499
+ // `int`, matching Ruby's real DDL: tokio_postgres requires the
500
+ // Rust and Postgres types to match, not just be compatible.
501
+ client
502
+ .batch_execute("CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)")
503
+ .await
504
+ .unwrap();
505
+ client
506
+ .execute(
507
+ "INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ($1, $2, 'test')",
508
+ &[&domain, &era],
509
+ )
510
+ .await
511
+ .unwrap();
512
+
513
+ let journal_table = format!("hecks_journal_{}", journal::snake(domain));
514
+ client
515
+ .batch_execute(&format!(
516
+ "CREATE TABLE IF NOT EXISTS \"{journal_table}\" (
517
+ ordinal bigserial PRIMARY KEY,
518
+ era int NOT NULL,
519
+ aggregate text NOT NULL,
520
+ aggregate_id text NOT NULL,
521
+ operation text NOT NULL,
522
+ state jsonb
523
+ )"
524
+ ))
525
+ .await
526
+ .unwrap();
527
+
528
+ for name in aggregate_storage_names {
529
+ // Domain-qualified (ADR 0059), matching what a real write
530
+ // targets.
531
+ let snapshot_table = journal::qualified_name(domain, &format!("{}_head_snapshot_{era}", journal::snake(name)));
532
+ client
533
+ .batch_execute(&format!(
534
+ "CREATE TABLE IF NOT EXISTS \"{snapshot_table}\" (id text PRIMARY KEY, ordinal bigint NOT NULL, \
535
+ operation text NOT NULL DEFAULT 'save', state jsonb)"
536
+ ))
537
+ .await
538
+ .unwrap();
539
+ }
540
+ }
541
+
542
+ // Pins a real outage: `era.is_some()` (lineage exists) and "this
543
+ // aggregate has a mirror" are different questions. Under ADR 0034 an
544
+ // aggregate outside the declared capable set has no head snapshot to
545
+ // upsert into.
546
+ #[tokio::test]
547
+ async fn a_domain_whose_ir_declares_nothing_lineage_capable_mirrors_nothing_and_still_writes() {
548
+ let client = scratch_db("rust_host_mirrors_nothing").await;
549
+ {
550
+ let guard = client.lock().await;
551
+ // No head snapshot for Customer, as `mint` leaves an empty
552
+ // capable set.
553
+ guard.batch_execute("CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)").await.unwrap();
554
+ guard.execute("INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ('Banking', 1, 'test')", &[]).await.unwrap();
555
+ guard
556
+ .batch_execute(
557
+ "CREATE TABLE IF NOT EXISTS hecks_journal_banking (ordinal bigserial PRIMARY KEY, era int NOT NULL, \
558
+ aggregate text NOT NULL, aggregate_id text NOT NULL, operation text NOT NULL, state jsonb)",
559
+ )
560
+ .await
561
+ .unwrap();
562
+ }
563
+ let wasm = wasm_path();
564
+ let invoker = crate::lambda_client::NeverInvoker;
565
+ let open = register("CUST-1");
566
+
567
+ // Mirroring everything is the outage: no head snapshot exists to
568
+ // upsert into.
569
+ let everything = LineageConfig { domain: "Banking".to_string(), era: Some(1), mirrored: None };
570
+ let refused = handle_facts(&client, &wasm, "Banking::Customer.Register", open.clone(), None, &everything, &invoker).await;
571
+ let message = match refused {
572
+ Ok(_) => panic!("expected the missing-relation failure"),
573
+ Err(e) => format!("{e:#}"),
574
+ };
575
+ assert!(message.contains("banking_customer_head_snapshot_1"), "names the relation it could not write: {message}");
576
+ assert!(message.contains("does not exist"), "carries the database's own words: {message}");
577
+ assert!(message.contains("Banking::Customer"), "names the record: {message}");
578
+
579
+ // Mirroring what the IR actually declares — nothing — writes
580
+ // cleanly, into this crate's own journal.
581
+ let declared = LineageConfig {
582
+ domain: "Banking".to_string(),
583
+ era: Some(1),
584
+ mirrored: Some(std::collections::BTreeSet::new()),
585
+ };
586
+ let outcome = handle_facts(&client, &wasm, "Banking::Customer.Register", open, None, &declared, &invoker)
587
+ .await
588
+ .expect("writes");
589
+ assert!(outcome.accepted, "{}", outcome.result);
590
+
591
+ let guard = client.lock().await;
592
+ let journalled: i64 = guard.query_one("SELECT count(*) FROM hecks_lambda_journal", &[]).await.unwrap().get(0);
593
+ assert_eq!(journalled, 1, "durable in this crate's own journal, which is what `read` replays");
594
+ let mirror: Option<String> =
595
+ guard.query_one("SELECT to_regclass('banking_customer_head_snapshot_1')::text", &[]).await.unwrap().get(0);
596
+ assert!(mirror.is_none(), "and no mirror was invented for an aggregate the IR never called capable");
597
+ }
598
+
599
+ // The other half: an aggregate the IR does call capable is still
600
+ // mirrored.
601
+ #[tokio::test]
602
+ async fn an_aggregate_the_ir_declares_capable_is_still_mirrored() {
603
+ let client = scratch_db("rust_host_mirrors_the_capable_one").await;
604
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
605
+ let config = LineageConfig {
606
+ domain: "Banking".to_string(),
607
+ era: Some(1),
608
+ mirrored: Some(["Banking::Customer".to_string()].into_iter().collect()),
609
+ };
610
+
611
+ handle_facts(
612
+ &client,
613
+ &wasm_path(),
614
+ "Banking::Customer.Register",
615
+ register("CUST-2"),
616
+ None,
617
+ &config,
618
+ &crate::lambda_client::NeverInvoker,
619
+ )
620
+ .await
621
+ .expect("writes");
622
+
623
+ let guard = client.lock().await;
624
+ let mirrored: i64 =
625
+ guard.query_one("SELECT count(*) FROM banking_customer_head_snapshot_1", &[]).await.unwrap().get(0);
626
+ assert_eq!(mirrored, 1);
627
+ }
628
+
629
+ fn test_config(domain: &str, era: i32) -> LineageConfig {
630
+ LineageConfig { domain: domain.to_string(), era: Some(era), mirrored: None }
631
+ }
632
+
633
+ pub(crate) fn wasm_path() -> std::path::PathBuf {
634
+ std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/banking.wasm")
635
+ }
636
+
637
+ fn register(reference: &str) -> serde_json::Value {
638
+ serde_json::json!({
639
+ "reference": { "value": reference },
640
+ "name": { "given": "Ada", "family": "Lovelace" },
641
+ "email": { "address": "ada@example.com" }
642
+ })
643
+ }
644
+
645
+ // `query` end to end through the real compiled kernel: a declared
646
+ // query answered against whatever state the journal currently holds.
647
+ #[tokio::test]
648
+ async fn a_declared_query_answers_against_current_state() {
649
+ let client = scratch_db("rust_host_dispatch_test_query").await;
650
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
651
+ let config = test_config("Banking", 1);
652
+
653
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0100"), None, &config, &lambda_client::NeverInvoker)
654
+ .await
655
+ .unwrap()
656
+ .accepted
657
+ .then_some(())
658
+ .expect("registering a customer should succeed");
659
+
660
+ // `Customer.Suspended` is `where status == "suspended"` — a
661
+ // freshly registered customer is active, so the query has to
662
+ // answer with a real, empty row set, not everything.
663
+ let before = query(&client, &wasm_path(), "Banking::Customer.Suspended", serde_json::json!({})).await.unwrap();
664
+ assert_eq!(before["queries"][0]["query"], "Banking::Customer.Suspended");
665
+ assert_eq!(before["queries"][0]["rows"].as_array().expect("rows").len(), 0);
666
+
667
+ handle(
668
+ &client,
669
+ &wasm_path(),
670
+ "Banking::Customer.Suspend",
671
+ serde_json::json!({ "reference": "CUST-0100", "standing": { "value": "watch" } }),
672
+ None,
673
+ &config,
674
+ &lambda_client::NeverInvoker,
675
+ )
676
+ .await
677
+ .unwrap()
678
+ .accepted
679
+ .then_some(())
680
+ .expect("suspending a customer should succeed");
681
+
682
+ let after = query(&client, &wasm_path(), "Banking::Customer.Suspended", serde_json::json!({})).await.unwrap();
683
+ let rows = after["queries"][0]["rows"].as_array().expect("rows");
684
+ assert_eq!(rows.len(), 1, "the suspended customer should be the one row: {after}");
685
+ assert_eq!(rows[0]["id"], "CUST-0100");
686
+ }
687
+
688
+ #[tokio::test]
689
+ async fn accepts_and_persists_a_first_command() {
690
+ let client = scratch_db("rust_host_dispatch_test_1").await;
691
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
692
+
693
+ let outcome = handle(
694
+ &client,
695
+ &wasm_path(),
696
+ "Banking::Customer.Register",
697
+ register("CUST-0001"),
698
+ None,
699
+ &test_config("Banking", 1),
700
+ &lambda_client::NeverInvoker,
701
+ )
702
+ .await
703
+ .unwrap();
704
+
705
+ assert!(outcome.accepted);
706
+ assert_eq!(outcome.result["refusals"].as_array().unwrap().len(), 0);
707
+ let guard = client.lock().await;
708
+ let steps = journal::load_steps(&*guard).await.unwrap();
709
+ assert_eq!(steps.len(), 1);
710
+
711
+ // The lineage write itself: a row landed in the era-tagged,
712
+ // storage-name-keyed journal shape, not just the flat log above.
713
+ let journal_rows = guard
714
+ .query("SELECT era, aggregate, aggregate_id, operation FROM hecks_journal_banking", &[])
715
+ .await
716
+ .unwrap();
717
+ assert_eq!(journal_rows.len(), 1);
718
+ let row = &journal_rows[0];
719
+ let era: i32 = row.get(0);
720
+ let aggregate: String = row.get(1);
721
+ let aggregate_id: String = row.get(2);
722
+ let operation: String = row.get(3);
723
+ assert_eq!((era, aggregate.as_str(), aggregate_id.as_str(), operation.as_str()), (1, "customer", "CUST-0001", "save"));
724
+
725
+ // domain-qualified (docs/decisions/0059) — snake("Banking") == "banking".
726
+ let snapshot_rows = guard
727
+ .query("SELECT id FROM banking_customer_head_snapshot_1", &[])
728
+ .await
729
+ .unwrap();
730
+ assert_eq!(snapshot_rows.len(), 1, "the head-snapshot table should carry exactly the one live record");
731
+ let id: String = snapshot_rows[0].get(0);
732
+ assert_eq!(id, "CUST-0001");
733
+ }
734
+
735
+ // Proves `occurred_at` end to end through the real wasm module: the
736
+ // string this crate stamps and sends is the one that comes back out
737
+ // in the returned event, not just what the in-kernel unit tests prove.
738
+ #[tokio::test]
739
+ async fn occurred_at_is_stamped_onto_every_returned_event_with_this_crate_s_own_real_clock() {
740
+ let client = scratch_db("rust_host_dispatch_test_occurred_at").await;
741
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
742
+
743
+ let before = crate::auth::httpdate_now();
744
+ let outcome = handle(
745
+ &client,
746
+ &wasm_path(),
747
+ "Banking::Customer.Register",
748
+ register("CUST-OCCURRED-AT"),
749
+ None,
750
+ &test_config("Banking", 1),
751
+ &lambda_client::NeverInvoker,
752
+ )
753
+ .await
754
+ .unwrap();
755
+ let after = crate::auth::httpdate_now();
756
+
757
+ assert!(outcome.accepted, "{:?}", outcome.result["refusals"]);
758
+ let events = outcome.result["events"].as_array().unwrap();
759
+ assert_eq!(events.len(), 1);
760
+ let occurred_at = events[0]["occurred_at"].as_str().expect("occurred_at should be a real string, not null or absent");
761
+ // Lexical comparison is valid: this format's digit-then-letter
762
+ // layout sorts identically to chronological order for stamps
763
+ // less than 10,000 years apart.
764
+ assert!(occurred_at >= before.as_str() && occurred_at <= after.as_str(), "{occurred_at} should fall between {before} and {after}");
765
+ }
766
+
767
+ #[tokio::test]
768
+ async fn rehydrates_prior_history_before_evaluating_the_new_command() {
769
+ let client = scratch_db("rust_host_dispatch_test_2").await;
770
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
771
+ let config = test_config("Banking", 1);
772
+
773
+ let first = handle(
774
+ &client,
775
+ &wasm_path(),
776
+ "Banking::Customer.Register",
777
+ register("CUST-0001"),
778
+ None,
779
+ &config,
780
+ &lambda_client::NeverInvoker,
781
+ )
782
+ .await
783
+ .unwrap();
784
+ assert!(
785
+ first.accepted,
786
+ "first registration should succeed: {:?}",
787
+ first.result
788
+ );
789
+
790
+ // Proves rehydration happened, not just that two calls each
791
+ // succeeded independently: the second registration only refuses
792
+ // if the first's effect was genuinely rebuilt from Postgres.
793
+ let second = handle(
794
+ &client,
795
+ &wasm_path(),
796
+ "Banking::Customer.Register",
797
+ register("CUST-0001"),
798
+ None,
799
+ &config,
800
+ &lambda_client::NeverInvoker,
801
+ )
802
+ .await
803
+ .unwrap();
804
+
805
+ assert!(
806
+ !second.accepted,
807
+ "duplicate registration should be refused, proving prior state was rehydrated: {:?}",
808
+ second.result
809
+ );
810
+ let steps = journal::load_steps(&*client.lock().await).await.unwrap();
811
+ assert_eq!(
812
+ steps.len(),
813
+ 1,
814
+ "the refused duplicate must not be persisted"
815
+ );
816
+ }
817
+
818
+ #[tokio::test]
819
+ async fn the_snapshot_stays_current_so_nothing_replays_full_history() {
820
+ // Direct proof (vs. the duplicate-refusal side effect above):
821
+ // after every accepted command, replaying past the snapshot's
822
+ // ordinal should find nothing left to replay.
823
+ let client = scratch_db("rust_host_dispatch_test_5").await;
824
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
825
+ let config = test_config("Banking", 1);
826
+
827
+ for reference in ["CUST-0004", "CUST-0005", "CUST-0006"] {
828
+ let outcome = handle(&client, &wasm_path(), "Banking::Customer.Register", register(reference), None, &config, &lambda_client::NeverInvoker)
829
+ .await
830
+ .unwrap();
831
+ assert!(outcome.accepted, "registering {reference} should succeed: {:?}", outcome.result);
832
+
833
+ let guard = client.lock().await;
834
+ let snapshot = journal::load_snapshot(&*guard)
835
+ .await
836
+ .unwrap()
837
+ .expect("a snapshot should exist after the first accepted command");
838
+ let tail = journal::load_steps_after(&*guard, snapshot.ordinal).await.unwrap();
839
+ assert!(tail.is_empty(), "the snapshot should already reflect every accepted command, leaving nothing to replay");
840
+
841
+ // And the seed is right, not just non-empty: it should carry
842
+ // every customer registered so far, not just this one.
843
+ let seeded = snapshot.seed.as_object().unwrap();
844
+ assert!(
845
+ seeded.keys().any(|k| k.contains(reference)),
846
+ "the snapshot should carry the record just dispatched: {seeded:?}"
847
+ );
848
+ }
849
+
850
+ let final_snapshot = journal::load_snapshot(&*client.lock().await).await.unwrap().unwrap();
851
+ let final_seed = final_snapshot.seed.as_object().unwrap();
852
+ assert_eq!(final_seed.len(), 3, "the snapshot should accumulate all three registrations, not just the latest: {final_seed:?}");
853
+ }
854
+
855
+ #[test]
856
+ fn a_top_level_error_is_a_kernel_failure_and_nothing_else_is() {
857
+ let failed = serde_json::json!({"error": "invalid seed: Registration.status: missing from JSON args"});
858
+ assert_eq!(classify(&failed), KernelAnswer::Failed("invalid seed: Registration.status: missing from JSON args".to_string()));
859
+ assert_eq!(classify(&serde_json::json!({"error": {"code": 7}})), KernelAnswer::Failed("{\"code\":7}".to_string()), "a non-text error is still a failure");
860
+
861
+ let accepted = serde_json::json!({"instances": {}, "events": [], "refusals": []});
862
+ assert_eq!(classify(&accepted), KernelAnswer::Accepted);
863
+
864
+ // A refusal carries its own nested `error`; that is the module deciding,
865
+ // not the kernel failing.
866
+ let refused = serde_json::json!({
867
+ "instances": {}, "events": [],
868
+ "refusals": [{"verb": "Banking::Customer.Register", "error": "already exists", "kind": "AlreadyExists"}]
869
+ });
870
+ assert_eq!(classify(&refused), KernelAnswer::Refused);
871
+
872
+ // A run whose query step failed reports it inside `queries`, still a finished run.
873
+ let query_error = serde_json::json!({"instances": {}, "refusals": [], "queries": [{"query": "X", "rows": null, "error": "no such query"}]});
874
+ assert_eq!(classify(&query_error), KernelAnswer::Accepted);
875
+ }
876
+
877
+ #[test]
878
+ fn parse_kernel_output_turns_a_failure_into_an_error_and_keeps_refusals_as_results() {
879
+ let error = parse_kernel_output(r#"{"error":"invalid seed: Customer.status: missing from JSON args"}"#).unwrap_err();
880
+ assert!(format!("{error:#}").contains("invalid seed: Customer.status: missing from JSON args"), "{error:#}");
881
+ let refused = parse_kernel_output(r#"{"instances":{},"refusals":[{"verb":"v","error":"no","kind":"K"}]}"#).unwrap();
882
+ assert_eq!(classify(&refused), KernelAnswer::Refused);
883
+ assert!(parse_kernel_output("not json").is_err());
884
+ }
885
+
886
+ #[tokio::test]
887
+ async fn a_kernel_failure_is_an_error_and_writes_nothing() {
888
+ // Simulates a snapshot missing a field its shape now requires.
889
+ // The kernel refuses to load it; this must fail the call and
890
+ // leave the journal and snapshot untouched.
891
+ let client = scratch_db("rust_host_dispatch_kernel_failure").await;
892
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
893
+ let config = test_config("Banking", 1);
894
+ let noinvoke = lambda_client::NeverInvoker;
895
+
896
+ let first = handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0001"), None, &config, &noinvoke).await.unwrap();
897
+ assert!(first.accepted, "{:?}", first.result);
898
+
899
+ let whole_world = journal::load_snapshot(&*client.lock().await).await.unwrap().unwrap().seed;
900
+ let mut damaged = whole_world.clone();
901
+ let customer = damaged.as_object_mut().unwrap().values_mut().next().unwrap().as_object_mut().unwrap();
902
+ assert!(customer.remove("status").is_some(), "the fixture Customer carries a status");
903
+ client.lock().await.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&damaged]).await.unwrap();
904
+
905
+ let before = snapshot_and_journal(&client).await;
906
+ let failure = handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0002"), None, &config, &noinvoke).await;
907
+ let message = format!("{:#}", failure.err().expect("a kernel failure must be an error, not an accepted outcome"));
908
+ assert!(message.contains("invalid seed") && message.contains("status"), "{message}");
909
+
910
+ assert_eq!(snapshot_and_journal(&client).await, before, "the failed command left the journal and the snapshot byte-identical");
911
+ assert_eq!(before.2, 1, "and only the first command was ever journaled");
912
+ let lineage_rows: i64 = client.lock().await.query_one("SELECT count(*) FROM hecks_journal_banking", &[]).await.unwrap().get(0);
913
+ assert_eq!(lineage_rows, 1, "nothing was mirrored into the era journal either");
914
+
915
+ // A read that must run the kernel over the damaged snapshot fails
916
+ // the same way, instead of answering an empty world.
917
+ client
918
+ .lock()
919
+ .await
920
+ .execute("INSERT INTO hecks_lambda_journal (verb, args) VALUES ($1, $2)", &[&"Banking::Customer.Register", &register("CUST-0009")])
921
+ .await
922
+ .unwrap();
923
+ let read_failure = read(&client, &wasm_path()).await.err().expect("a kernel failure is not an empty read");
924
+ assert!(format!("{read_failure:#}").contains("invalid seed"), "{read_failure:#}");
925
+ let query_failure = query(&client, &wasm_path(), "Banking::Customer.Suspended", serde_json::json!({})).await.err();
926
+ assert!(query_failure.is_some_and(|e| format!("{e:#}").contains("invalid seed")));
927
+ client.lock().await.execute("DELETE FROM hecks_lambda_journal WHERE verb = 'Banking::Customer.Register' AND ordinal > 1", &[]).await.unwrap();
928
+
929
+ // Once the snapshot is whole again the next command works, and the
930
+ // snapshot afterwards holds the whole world, not just its own instance.
931
+ client.lock().await.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&whole_world]).await.unwrap();
932
+ let second = handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0002"), None, &config, &noinvoke).await.unwrap();
933
+ assert!(second.accepted, "{:?}", second.result);
934
+ let after = journal::load_snapshot(&*client.lock().await).await.unwrap().unwrap().seed;
935
+ let keys: Vec<&String> = after.as_object().unwrap().keys().collect();
936
+ assert_eq!(keys.len(), 2, "the snapshot holds both customers: {keys:?}");
937
+ assert!(keys.iter().any(|k| k.contains("CUST-0001")) && keys.iter().any(|k| k.contains("CUST-0002")), "{keys:?}");
938
+ }
939
+
940
+ // The snapshot's ordinal and exact seed text, and how many commands are journaled.
941
+ async fn snapshot_and_journal(client: &Mutex<Client>) -> (i64, String, i64) {
942
+ let guard = client.lock().await;
943
+ let snapshot = guard.query_one("SELECT ordinal, seed::text FROM hecks_lambda_snapshot", &[]).await.unwrap();
944
+ let journaled: i64 = guard.query_one("SELECT count(*) FROM hecks_lambda_journal", &[]).await.unwrap().get(0);
945
+ (snapshot.get(0), snapshot.get(1), journaled)
946
+ }
947
+
948
+ #[tokio::test]
949
+ async fn serializes_concurrent_invocations_against_the_same_journal() {
950
+ // **The race the advisory lock closes**: without it, two concurrent
951
+ // registrations of the same reference could both rehydrate
952
+ // against zero prior steps, both see no conflict, and both get
953
+ // appended — silently violating the uniqueness the domain
954
+ // itself enforces. With the lock serializing the whole
955
+ // read-then-append sequence, exactly one must be accepted.
956
+ let client = scratch_db("rust_host_dispatch_test_3").await;
957
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
958
+ let config = test_config("Banking", 1);
959
+ let wasm_path = wasm_path();
960
+
961
+ let (first, second) = tokio::join!(
962
+ handle(&client, &wasm_path, "Banking::Customer.Register", register("CUST-0002"), None, &config, &lambda_client::NeverInvoker),
963
+ handle(&client, &wasm_path, "Banking::Customer.Register", register("CUST-0002"), None, &config, &lambda_client::NeverInvoker),
964
+ );
965
+ let (first, second) = (first.unwrap(), second.unwrap());
966
+
967
+ assert_ne!(
968
+ first.accepted, second.accepted,
969
+ "exactly one of two concurrent duplicate registrations should be accepted: {:?} / {:?}",
970
+ first.result, second.result
971
+ );
972
+ let steps = journal::load_steps(&*client.lock().await).await.unwrap();
973
+ assert_eq!(steps.len(), 1, "only the accepted registration should be persisted");
974
+ }
975
+
976
+ #[tokio::test]
977
+ async fn read_reflects_prior_dispatches_without_persisting_anything_new() {
978
+ let client = scratch_db("rust_host_dispatch_test_4").await;
979
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
980
+
981
+ handle(
982
+ &client,
983
+ &wasm_path(),
984
+ "Banking::Customer.Register",
985
+ register("CUST-0003"),
986
+ None,
987
+ &test_config("Banking", 1),
988
+ &lambda_client::NeverInvoker,
989
+ )
990
+ .await
991
+ .unwrap()
992
+ .accepted
993
+ .then_some(())
994
+ .expect("registration should succeed");
995
+
996
+ let result = read(&client, &wasm_path()).await.unwrap();
997
+
998
+ assert!(
999
+ result["refusals"].as_array().unwrap().is_empty(),
1000
+ "a read replaying only already-accepted history should never refuse: {result:?}"
1001
+ );
1002
+ let instances = result["instances"].as_object().unwrap();
1003
+ assert!(
1004
+ instances.keys().any(|k| k.contains("CUST-0003")),
1005
+ "read should reflect the prior dispatch: {instances:?}"
1006
+ );
1007
+
1008
+ // The sharp proof a read never appends: the journal is exactly
1009
+ // as long after the read as it was before it.
1010
+ let steps = journal::load_steps(&*client.lock().await).await.unwrap();
1011
+ assert_eq!(steps.len(), 1, "a read must never persist a journal row");
1012
+ }
1013
+
1014
+ // Records every call and answers with a canned "nothing refused"
1015
+ // body, proving a cross-domain policy reaches `lambda_client::deliver`
1016
+ // with the right name/payload end to end, short of a real AWS Lambda.
1017
+ struct RecordingInvoker {
1018
+ calls: std::sync::Mutex<Vec<(String, String)>>,
1019
+ }
1020
+
1021
+ impl RecordingInvoker {
1022
+ fn new() -> Self {
1023
+ Self { calls: std::sync::Mutex::new(Vec::new()) }
1024
+ }
1025
+ }
1026
+
1027
+ #[async_trait::async_trait]
1028
+ impl LambdaInvoker for RecordingInvoker {
1029
+ async fn invoke(&self, function_name: &str, payload: &str) -> anyhow::Result<lambda_client::InvokeOutcome> {
1030
+ self.calls.lock().unwrap().push((function_name.to_string(), payload.to_string()));
1031
+ Ok(lambda_client::InvokeOutcome { body: serde_json::json!({ "refusals": [] }), function_error: false })
1032
+ }
1033
+ }
1034
+
1035
+ #[tokio::test]
1036
+ async fn a_cross_domain_policy_delivers_through_the_real_rehydrate_replay_path() {
1037
+ let client = scratch_db("rust_host_dispatch_test_6").await;
1038
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account"]).await;
1039
+ let config = test_config("Banking", 1);
1040
+ let invoker = RecordingInvoker::new();
1041
+
1042
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0007"), None, &config, &invoker)
1043
+ .await
1044
+ .unwrap()
1045
+ .accepted
1046
+ .then_some(())
1047
+ .expect("registration should succeed");
1048
+
1049
+ let open_args = serde_json::json!({
1050
+ "number": { "value": "acct-freeze-me" },
1051
+ "kind": { "name": "current" },
1052
+ "daily_limit": { "cents": 50000 },
1053
+ "customer": "CUST-0007",
1054
+ });
1055
+ handle(&client, &wasm_path(), "Banking::Account.Open", open_args, None, &config, &invoker)
1056
+ .await
1057
+ .unwrap()
1058
+ .accepted
1059
+ .then_some(())
1060
+ .expect("account open should succeed");
1061
+
1062
+ // `FreezeAccount` announces `AccountFrozen`, matched by
1063
+ // `ReviewOnFreeze` (`across "Compliance"`) — the real trigger
1064
+ // this feature exists for.
1065
+ let freeze_args = serde_json::json!({ "number": { "value": "acct-freeze-me" } });
1066
+ let outcome = handle(&client, &wasm_path(), "Banking::Account.FreezeAccount", freeze_args, None, &config, &invoker)
1067
+ .await
1068
+ .unwrap();
1069
+ assert!(outcome.accepted, "freeze should succeed: {:?}", outcome.result);
1070
+
1071
+ let deliveries = outcome.result["cross_domain_deliveries"].as_array().unwrap();
1072
+ assert_eq!(deliveries.len(), 1, "exactly one cross-domain reaction should have fired: {deliveries:?}");
1073
+ assert_eq!(deliveries[0]["policy"], "ReviewOnFreeze");
1074
+ assert_eq!(deliveries[0]["target_domain"], "Compliance");
1075
+ assert_eq!(deliveries[0]["delivered"], true);
1076
+
1077
+ // The same delivery also merges into "reactions"
1078
+ // ({policy, on, trigger, delivered, reason}), alongside the
1079
+ // differently-shaped "cross_domain_deliveries" entry above.
1080
+ let reactions = outcome.result["reactions"].as_array().unwrap();
1081
+ let merged = reactions.iter().find(|r| r["policy"] == "ReviewOnFreeze").expect("ReviewOnFreeze should appear in \"reactions\" too");
1082
+ assert_eq!(merged["on"], "AccountFrozen");
1083
+ assert_eq!(merged["trigger"], "Compliance::AccountFreezeReview.Open");
1084
+ assert_eq!(merged["delivered"], true);
1085
+
1086
+ {
1087
+ let calls = invoker.calls.lock().unwrap();
1088
+ assert_eq!(calls.len(), 1);
1089
+ let (function_name, payload) = &calls[0];
1090
+ assert_eq!(function_name, "hecks-compliance");
1091
+ let sent: serde_json::Value = serde_json::from_str(payload).unwrap();
1092
+ // Fully qualified, not the bare "Compliance.OpenReview":
1093
+ // generated match arms are always "Domain::Aggregate.Command",
1094
+ // and Compliance's own aggregate is named `AccountFreezeReview`.
1095
+ assert_eq!(sent["verb"], "Compliance::AccountFreezeReview.Open");
1096
+ assert_eq!(sent["args"]["number"]["value"], "acct-freeze-me");
1097
+ }
1098
+
1099
+ // Never re-delivered on a later replay: `pending_cross_domain`
1100
+ // only ever reads the last step's own reactions, so rehydrating
1101
+ // history here doesn't re-invoke the sibling Lambda.
1102
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0008"), None, &config, &invoker)
1103
+ .await
1104
+ .unwrap();
1105
+ assert_eq!(invoker.calls.lock().unwrap().len(), 1, "replaying prior history must not re-deliver its cross-domain reaction");
1106
+ }
1107
+
1108
+ // Pins the fix: this is the first test that reaches `check_role`
1109
+ // with a caller role actually bound and checked, not the unchecked
1110
+ // "no role at all" path every other test in this file uses.
1111
+ // `Register` requires `Some("Branch clerk")` in the generated
1112
+ // registry — real, corpus-declared.
1113
+ #[tokio::test]
1114
+ async fn a_role_gated_command_is_actually_checked_against_the_caller_role() {
1115
+ let client = scratch_db("rust_host_dispatch_test_7").await;
1116
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
1117
+ let config = test_config("Banking", 1);
1118
+
1119
+ // The correct role succeeds, same as every other registration,
1120
+ // just now with a caller actually bound and matching.
1121
+ let matching = handle(
1122
+ &client,
1123
+ &wasm_path(),
1124
+ "Banking::Customer.Register",
1125
+ register("CUST-0009"),
1126
+ Some("Branch clerk"),
1127
+ &config,
1128
+ &lambda_client::NeverInvoker,
1129
+ )
1130
+ .await
1131
+ .unwrap();
1132
+ assert!(
1133
+ matching.accepted,
1134
+ "the declared role should be admitted: {:?}",
1135
+ matching.result
1136
+ );
1137
+
1138
+ // The actual proof: with role genuinely threaded through, a
1139
+ // caller stating the wrong role must be refused, not silently
1140
+ // admitted via the unchecked no-role path.
1141
+ let mismatched = handle(
1142
+ &client,
1143
+ &wasm_path(),
1144
+ "Banking::Customer.Register",
1145
+ register("CUST-0010"),
1146
+ Some("Teller"),
1147
+ &config,
1148
+ &lambda_client::NeverInvoker,
1149
+ )
1150
+ .await
1151
+ .unwrap();
1152
+ assert!(
1153
+ !mismatched.accepted,
1154
+ "a caller stating the wrong role must be refused, not silently admitted: {:?}",
1155
+ mismatched.result
1156
+ );
1157
+ let refusals = mismatched.result["refusals"].as_array().unwrap();
1158
+ assert_eq!(refusals.len(), 1);
1159
+ let error = refusals[0]["error"].as_str().unwrap();
1160
+ assert!(
1161
+ error.contains("Branch clerk") && error.contains("Teller"),
1162
+ "the refusal should name both the declared role and the caller's mismatched one \
1163
+ (`check_role`'s own message shape): {refusals:?}"
1164
+ );
1165
+
1166
+ // And the refused command was never persisted — same discipline
1167
+ // every other refusal in this file is held to.
1168
+ let steps = journal::load_steps(&*client.lock().await).await.unwrap();
1169
+ assert_eq!(steps.len(), 1, "only the correctly-authorized registration should be persisted");
1170
+ }
1171
+
1172
+ #[tokio::test]
1173
+ async fn a_mid_transaction_failure_rolls_back_any_saga_state_already_written() {
1174
+ // Deliberately doesn't provision "Transfer": its mutation fails
1175
+ // after `begin_saga` already wrote into `hecks_lambda_sagas`,
1176
+ // proving that write rolls back with the rest of the transaction.
1177
+ let client = scratch_db("rust_host_dispatch_test_9").await;
1178
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account"]).await;
1179
+ let config = test_config("Banking", 1);
1180
+
1181
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0012"), None, &config, &lambda_client::NeverInvoker)
1182
+ .await.unwrap().accepted.then_some(()).expect("registration should succeed");
1183
+
1184
+ let open_src = serde_json::json!({
1185
+ "number": { "value": "src-rollback" }, "kind": { "name": "current" },
1186
+ "daily_limit": { "cents": 50000 }, "customer": "CUST-0012",
1187
+ });
1188
+ handle(&client, &wasm_path(), "Banking::Account.Open", open_src, None, &config, &lambda_client::NeverInvoker)
1189
+ .await.unwrap().accepted.then_some(()).expect("source account open should succeed");
1190
+ let open_dst = serde_json::json!({
1191
+ "number": { "value": "dst-rollback" }, "kind": { "name": "current" },
1192
+ "daily_limit": { "cents": 50000 }, "customer": "CUST-0012",
1193
+ });
1194
+ handle(&client, &wasm_path(), "Banking::Account.Open", open_dst, None, &config, &lambda_client::NeverInvoker)
1195
+ .await.unwrap().accepted.then_some(()).expect("destination account open should succeed");
1196
+ handle(
1197
+ &client, &wasm_path(), "Banking::Account.Credit",
1198
+ serde_json::json!({ "number": "src-rollback", "amount": { "cents": 1000 }, "narrative": { "text": "opening balance" } }),
1199
+ None, &config, &lambda_client::NeverInvoker,
1200
+ )
1201
+ .await.unwrap().accepted.then_some(()).expect("credit should succeed");
1202
+
1203
+ // `Transfer.Request`'s own mutation is the first one this step
1204
+ // produces, and "Transfer" was never provisioned, so it fails
1205
+ // before the saga cascade runs further.
1206
+ let transfer_args = serde_json::json!({
1207
+ "reference": { "value": "tr-rollback" }, "amount": { "cents": 200 },
1208
+ "narrative": { "text": "rollback test" }, "source": "src-rollback", "destination": "dst-rollback",
1209
+ });
1210
+ let outcome = handle(&client, &wasm_path(), "Banking::Transfer.Request", transfer_args, None, &config, &lambda_client::NeverInvoker).await;
1211
+ assert!(outcome.is_err(), "the unprovisioned Transfer lineage table should make this whole invocation fail");
1212
+
1213
+ let guard = client.lock().await;
1214
+ let saga_rows = guard.query("SELECT process_manager, correlation FROM hecks_lambda_sagas", &[]).await.unwrap();
1215
+ assert_eq!(
1216
+ saga_rows.len(), 0,
1217
+ "a saga write inside a transaction that ultimately fails must roll back with it, not commit independently: {saga_rows:?}"
1218
+ );
1219
+ }
1220
+
1221
+ #[tokio::test]
1222
+ async fn a_pre_existing_snapshot_without_the_sagas_backfilled_latch_forces_one_full_replay() {
1223
+ let client = scratch_db("rust_host_dispatch_test_10").await;
1224
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account", "Transfer"]).await;
1225
+ let config = test_config("Banking", 1);
1226
+ // `ReviewOnFreeze` fires on every `FreezeAccount` in this domain
1227
+ // (proven above), so `NeverInvoker` would panic here.
1228
+ let invoker = RecordingInvoker::new();
1229
+
1230
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0013"), None, &config, &invoker)
1231
+ .await.unwrap().accepted.then_some(()).expect("registration should succeed");
1232
+
1233
+ let open_src = serde_json::json!({
1234
+ "number": { "value": "src-backfill" }, "kind": { "name": "current" },
1235
+ "daily_limit": { "cents": 50000 }, "customer": "CUST-0013",
1236
+ });
1237
+ handle(&client, &wasm_path(), "Banking::Account.Open", open_src, None, &config, &invoker)
1238
+ .await.unwrap().accepted.then_some(()).expect("source account open should succeed");
1239
+ let open_dst = serde_json::json!({
1240
+ "number": { "value": "dst-backfill" }, "kind": { "name": "current" },
1241
+ "daily_limit": { "cents": 50000 }, "customer": "CUST-0013",
1242
+ });
1243
+ handle(&client, &wasm_path(), "Banking::Account.Open", open_dst, None, &config, &invoker)
1244
+ .await.unwrap().accepted.then_some(()).expect("destination account open should succeed");
1245
+ handle(
1246
+ &client, &wasm_path(), "Banking::Account.Credit",
1247
+ serde_json::json!({ "number": "src-backfill", "amount": { "cents": 1000 }, "narrative": { "text": "opening balance" } }),
1248
+ None, &config, &invoker,
1249
+ )
1250
+ .await.unwrap().accepted.then_some(()).expect("credit should succeed");
1251
+
1252
+ // Freezing the destination first means the credit leg refuses
1253
+ // and Settlement compensates, leaving the saga stuck "reversed"
1254
+ // — exactly the mid-flight shape this test needs to recover.
1255
+ handle(&client, &wasm_path(), "Banking::Account.FreezeAccount", serde_json::json!({ "number": { "value": "dst-backfill" } }), None, &config, &invoker)
1256
+ .await.unwrap().accepted.then_some(()).expect("freeze should succeed");
1257
+ let transfer_args = serde_json::json!({
1258
+ "reference": { "value": "tr-backfill" }, "amount": { "cents": 200 },
1259
+ "narrative": { "text": "backfill test" }, "source": "src-backfill", "destination": "dst-backfill",
1260
+ });
1261
+ handle(&client, &wasm_path(), "Banking::Transfer.Request", transfer_args, None, &config, &invoker)
1262
+ .await.unwrap().accepted.then_some(()).expect("the Transfer.Request COMMAND succeeds; the saga's own credit leg is what refuses");
1263
+
1264
+ {
1265
+ let guard = client.lock().await;
1266
+ let before = guard.query("SELECT process_manager, correlation, state FROM hecks_lambda_sagas", &[]).await.unwrap();
1267
+ assert_eq!(before.len(), 1, "the reversed Settlement instance should already be tracked before the simulated reset: {before:?}");
1268
+ }
1269
+
1270
+ // Simulates a pre-saga-durability snapshot: the latch false and
1271
+ // `hecks_lambda_sagas` empty, even though a saga is genuinely
1272
+ // mid-flight.
1273
+ {
1274
+ let guard = client.lock().await;
1275
+ guard.execute("UPDATE hecks_lambda_snapshot SET sagas_backfilled = false", &[]).await.unwrap();
1276
+ guard.execute("DELETE FROM hecks_lambda_sagas", &[]).await.unwrap();
1277
+ }
1278
+
1279
+ // One more command should trigger a full replay, whose saga
1280
+ // cascade re-running as a side effect repopulates
1281
+ // `hecks_lambda_sagas`.
1282
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0014"), None, &config, &invoker)
1283
+ .await.unwrap().accepted.then_some(()).expect("second registration should succeed");
1284
+
1285
+ let guard = client.lock().await;
1286
+ let saga_rows = guard.query("SELECT process_manager, correlation, state FROM hecks_lambda_sagas", &[]).await.unwrap();
1287
+ assert_eq!(saga_rows.len(), 1, "the backfill replay should have recovered the in-flight Settlement instance: {saga_rows:?}");
1288
+ let process_manager: String = saga_rows[0].get(0);
1289
+ let correlation: String = saga_rows[0].get(1);
1290
+ let state: String = saga_rows[0].get(2);
1291
+ assert_eq!(process_manager, "Settlement");
1292
+ assert_eq!(correlation, "tr-backfill");
1293
+ assert_eq!(state, "reversed");
1294
+
1295
+ let latched: bool = guard.query_one("SELECT sagas_backfilled FROM hecks_lambda_snapshot", &[]).await.unwrap().get(0);
1296
+ assert!(latched, "after the backfill replay, the latch should be set so this doesn't repeat every invocation");
1297
+ }
1298
+
1299
+ // Always fails with a hard invoke fault, the shape an unreachable
1300
+ // Lambda has — proves the exhausted-retry/dead-letter path.
1301
+ struct AlwaysFailingInvoker {
1302
+ calls: std::sync::Mutex<u32>,
1303
+ }
1304
+
1305
+ impl AlwaysFailingInvoker {
1306
+ fn new() -> Self {
1307
+ Self { calls: std::sync::Mutex::new(0) }
1308
+ }
1309
+ }
1310
+
1311
+ #[async_trait::async_trait]
1312
+ impl LambdaInvoker for AlwaysFailingInvoker {
1313
+ async fn invoke(&self, _function_name: &str, _payload: &str) -> anyhow::Result<lambda_client::InvokeOutcome> {
1314
+ *self.calls.lock().unwrap() += 1;
1315
+ anyhow::bail!("ResourceNotFoundException: function not found")
1316
+ }
1317
+ }
1318
+
1319
+ #[tokio::test]
1320
+ async fn a_cross_domain_delivery_that_exhausts_every_retry_dead_letters_and_still_fails_the_invocation() {
1321
+ let client = scratch_db("rust_host_dispatch_test_8").await;
1322
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account"]).await;
1323
+ let config = test_config("Banking", 1);
1324
+ let invoker = AlwaysFailingInvoker::new();
1325
+
1326
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0011"), None, &config, &invoker)
1327
+ .await
1328
+ .unwrap()
1329
+ .accepted
1330
+ .then_some(())
1331
+ .expect("registration should succeed");
1332
+
1333
+ let open_args = serde_json::json!({
1334
+ "number": { "value": "acct-freeze-dead-letter" },
1335
+ "kind": { "name": "current" },
1336
+ "daily_limit": { "cents": 50000 },
1337
+ "customer": "CUST-0011",
1338
+ });
1339
+ handle(&client, &wasm_path(), "Banking::Account.Open", open_args, None, &config, &invoker)
1340
+ .await
1341
+ .unwrap()
1342
+ .accepted
1343
+ .then_some(())
1344
+ .expect("account open should succeed");
1345
+
1346
+ // `ReviewOnFreeze` fires here — every attempt against
1347
+ // `AlwaysFailingInvoker` fails, so `deliver_with_retry` exhausts
1348
+ // `MAX_DELIVERY_ATTEMPTS` before `handle` ever sees a result.
1349
+ let freeze_args = serde_json::json!({ "number": { "value": "acct-freeze-dead-letter" } });
1350
+ let outcome = handle(&client, &wasm_path(), "Banking::Account.FreezeAccount", freeze_args, None, &config, &invoker).await;
1351
+
1352
+ assert!(
1353
+ outcome.is_err(),
1354
+ "a cross-domain delivery that exhausts every retry should still fail THIS invocation \
1355
+ visibly — the local Freeze already committed regardless (checked below), this is only \
1356
+ about what the CALLER sees"
1357
+ );
1358
+ assert_eq!(
1359
+ *invoker.calls.lock().unwrap(),
1360
+ lambda_client::MAX_DELIVERY_ATTEMPTS,
1361
+ "should have retried exactly MAX_DELIVERY_ATTEMPTS times before giving up"
1362
+ );
1363
+
1364
+ // The local command still committed: cross-domain delivery runs
1365
+ // strictly after commit, so a delivery failure never unwinds it.
1366
+ // The dead letter existing at all proves the match already ran.
1367
+ let guard = client.lock().await;
1368
+ let dead_letters = guard
1369
+ .query("SELECT policy, target_domain, target_verb, attempts FROM hecks_cross_domain_dead_letters", &[])
1370
+ .await
1371
+ .unwrap();
1372
+ assert_eq!(dead_letters.len(), 1, "exactly one exhausted delivery should have been dead-lettered: {dead_letters:?}");
1373
+ let policy: String = dead_letters[0].get(0);
1374
+ let target_domain: String = dead_letters[0].get(1);
1375
+ let target_verb: String = dead_letters[0].get(2);
1376
+ let attempts: i32 = dead_letters[0].get(3);
1377
+ assert_eq!(policy, "ReviewOnFreeze");
1378
+ assert_eq!(target_domain, "Compliance");
1379
+ assert_eq!(target_verb, "Compliance::AccountFreezeReview.Open");
1380
+ assert_eq!(attempts, lambda_client::MAX_DELIVERY_ATTEMPTS as i32);
1381
+ }
1382
+
1383
+ // Pins a real drop: a second cross-domain reaction from the same
1384
+ // step must not go dark just because the first one's delivery
1385
+ // exhausted its retries. `SafeDepositBox.Surrender` emits both
1386
+ // `BoxSurrendered` (-> Compliance) and `KeyReturnDue` (-> Notifications)
1387
+ // from one command.
1388
+ #[tokio::test]
1389
+ async fn a_failed_cross_domain_delivery_does_not_drop_a_sibling_reaction_from_the_same_step() {
1390
+ let client = scratch_db("rust_host_dispatch_test_11").await;
1391
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "SafeDepositBox"]).await;
1392
+ let config = test_config("Banking", 1);
1393
+ let invoker = AlwaysFailingInvoker::new();
1394
+
1395
+ handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0022"), None, &config, &invoker)
1396
+ .await
1397
+ .unwrap()
1398
+ .accepted
1399
+ .then_some(())
1400
+ .expect("registration should succeed");
1401
+
1402
+ let rent_args = serde_json::json!({
1403
+ "branch_code": { "value": "downtown" },
1404
+ "box_number": { "value": 12 },
1405
+ "size": { "value": "small" },
1406
+ "customer": "CUST-0022",
1407
+ });
1408
+ handle(&client, &wasm_path(), "Banking::SafeDepositBox.Rent", rent_args, None, &config, &invoker)
1409
+ .await
1410
+ .unwrap()
1411
+ .accepted
1412
+ .then_some(())
1413
+ .expect("rent should succeed");
1414
+
1415
+ // Both ReviewOnBoxSurrender (-> Compliance) and FlagKeyReturn
1416
+ // (-> Notifications) fire here; both deliveries exhaust every
1417
+ // retry against AlwaysFailingInvoker.
1418
+ let surrender_args = serde_json::json!({ "branch_code": { "value": "downtown" }, "box_number": { "value": 12 } });
1419
+ let outcome =
1420
+ handle(&client, &wasm_path(), "Banking::SafeDepositBox.Surrender", surrender_args, None, &config, &invoker)
1421
+ .await;
1422
+
1423
+ assert!(outcome.is_err(), "still fails the invocation visibly, same contract as a single failed delivery");
1424
+ assert_eq!(
1425
+ *invoker.calls.lock().unwrap(),
1426
+ lambda_client::MAX_DELIVERY_ATTEMPTS * 2,
1427
+ "BOTH reactions should have been attempted MAX_DELIVERY_ATTEMPTS times each — \
1428
+ the fix under test is exactly this: a fresh Vec would only ever show one policy's worth"
1429
+ );
1430
+
1431
+ let guard = client.lock().await;
1432
+ let dead_letters = guard
1433
+ .query("SELECT policy FROM hecks_cross_domain_dead_letters ORDER BY policy", &[])
1434
+ .await
1435
+ .unwrap();
1436
+ let policies: Vec<String> = dead_letters.iter().map(|row| row.get(0)).collect();
1437
+ assert_eq!(
1438
+ policies,
1439
+ vec!["FlagKeyReturn".to_string(), "ReviewOnBoxSurrender".to_string()],
1440
+ "BOTH sibling reactions must be dead-lettered, not just whichever ran first: {policies:?}"
1441
+ );
1442
+ }
1443
+ }