hecks 3.14.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (419) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/adapters/fake_processor.rb +34 -0
  3. data/examples/pizzas/adapters/payment_signature.rb +66 -0
  4. data/examples/pizzas/adapters/payment_webhook.rb +87 -0
  5. data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
  6. data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
  7. data/examples/pizzas/config.ru +22 -0
  8. data/lib/hecks/adapters/driven/prism.rb +0 -2
  9. data/lib/hecks/adapters/driven.rb +0 -2
  10. data/lib/hecks/adapters/driving/json.rb +1 -7
  11. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
  12. data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
  13. data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
  14. data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
  15. data/lib/hecks/bluebook/hecksagon.rb +1 -1
  16. data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
  17. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
  18. data/lib/hecks/bluebook/meta_validator.rb +0 -11
  19. data/lib/hecks/bluebook/translation.rb +18 -10
  20. data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
  21. data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
  22. data/lib/hecks/bluebook/translation_expression.rb +39 -0
  23. data/lib/hecks/bluebook/translation_plan.rb +70 -0
  24. data/lib/hecks/bluebook.rb +2 -0
  25. data/lib/hecks/chapters.rb +3 -3
  26. data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
  27. data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
  28. data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
  29. data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
  30. data/lib/hecks/composer/adapters/jev.adapter +3 -0
  31. data/lib/hecks/composer/adapters/jev.rb +66 -0
  32. data/lib/hecks/composer/agent.rb +113 -0
  33. data/lib/hecks/composer/assistant.rb +110 -0
  34. data/lib/hecks/composer/behavior_run.rb +53 -0
  35. data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
  36. data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
  37. data/lib/hecks/composer/board.rb +65 -0
  38. data/lib/hecks/composer/board_edit/command_text.rb +39 -0
  39. data/lib/hecks/composer/board_edit/source.rb +84 -0
  40. data/lib/hecks/composer/board_edit.rb +144 -0
  41. data/lib/hecks/composer/check.rb +89 -0
  42. data/lib/hecks/composer/domains.rb +81 -0
  43. data/lib/hecks/composer/jobs.rb +134 -0
  44. data/lib/hecks/composer/layout.rb +37 -0
  45. data/lib/hecks/composer/live.rb +150 -0
  46. data/lib/hecks/composer/outline.rb +48 -0
  47. data/lib/hecks/composer/page.html +989 -0
  48. data/lib/hecks/composer/panels.rb +142 -0
  49. data/lib/hecks/composer/parse.rb +83 -0
  50. data/lib/hecks/composer/plain.rb +28 -0
  51. data/lib/hecks/composer/ports/composer_agent.port +7 -0
  52. data/lib/hecks/composer/session.rb +141 -0
  53. data/lib/hecks/composer/toolbox.rb +166 -0
  54. data/lib/hecks/composer/web.rb +156 -0
  55. data/lib/hecks/composer/workbench.rb +51 -0
  56. data/lib/hecks/composer/workspace.rb +81 -0
  57. data/lib/hecks/composer.rb +17 -0
  58. data/lib/hecks/corpus/adapters.rb +22 -0
  59. data/lib/hecks/corpus/rust.rb +1 -1
  60. data/lib/hecks/corpus/worlds.rb +19 -0
  61. data/lib/hecks/corpus.rb +5 -0
  62. data/lib/hecks/forms/app/commands.rb +2 -3
  63. data/lib/hecks/forms/app/queries.rb +2 -3
  64. data/lib/hecks/forms/app/records.rb +17 -5
  65. data/lib/hecks/forms/command_form_renderer.rb +42 -16
  66. data/lib/hecks/forms/field_controls.rb +2 -2
  67. data/lib/hecks/forms/field_renderer.rb +1 -1
  68. data/lib/hecks/forms/field_shape.rb +36 -17
  69. data/lib/hecks/forms/index_renderer.rb +2 -1
  70. data/lib/hecks/forms/page.rb +1 -1
  71. data/lib/hecks/forms/params.rb +0 -13
  72. data/lib/hecks/forms/query_form_renderer.rb +7 -6
  73. data/lib/hecks/forms/record_renderer.rb +11 -7
  74. data/lib/hecks/forms/rule_words.rb +100 -0
  75. data/lib/hecks/forms/value_object_shape.rb +2 -1
  76. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
  77. data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
  78. data/lib/hecks/fuzzing/replay/filters.rb +2 -6
  79. data/lib/hecks/gate/stages.yml +25 -0
  80. data/lib/hecks/grammar/evolve/seeds.rb +0 -6
  81. data/lib/hecks/grammar/evolve.rb +0 -3
  82. data/lib/hecks/grammar/translation.bluebook +1 -1
  83. data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
  84. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  85. data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
  86. data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
  87. data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
  88. data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
  89. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
  90. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
  91. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
  92. data/lib/hecks/hecks/codebase.behaviors +6 -0
  93. data/lib/hecks/hecks/codebase.bluebook +21 -0
  94. data/lib/hecks/hecks/composer.behaviors +68 -0
  95. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  96. data/lib/hecks/hecks/custodian.behaviors +0 -6
  97. data/lib/hecks/hecks/custodian.bluebook +3 -80
  98. data/lib/hecks/hecks/hecks.hecksagon +2 -2
  99. data/lib/hecks/hecks/hecks.world +1 -1
  100. data/lib/hecks/hecks/rehearsal.bluebook +7 -13
  101. data/lib/hecks/kernel_capabilities/templates.rb +4 -4
  102. data/lib/hecks/kernel_capabilities.rb +1 -1
  103. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
  104. data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
  105. data/lib/hecks/ports/access_control.rb +2 -2
  106. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
  107. data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
  108. data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
  109. data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
  110. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
  111. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
  112. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
  113. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
  114. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
  115. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
  116. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
  117. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
  118. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
  119. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
  120. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
  121. data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
  122. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
  123. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
  124. data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
  125. data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
  126. data/lib/hecks/ports.rb +0 -1
  127. data/lib/hecks/projection_files.rb +3 -1
  128. data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
  129. data/lib/hecks/projections/glossary/entries.rb +10 -4
  130. data/lib/hecks/projections/glossary/groups.rb +42 -0
  131. data/lib/hecks/projections/glossary/html/page.rb +7 -2
  132. data/lib/hecks/projections/glossary/html/parser.rb +1 -1
  133. data/lib/hecks/projections/glossary/html/sections.rb +31 -11
  134. data/lib/hecks/projections/glossary/html.rb +1 -2
  135. data/lib/hecks/projections/glossary/markdown.rb +31 -28
  136. data/lib/hecks/projections/glossary/mermaid.rb +3 -0
  137. data/lib/hecks/projections/glossary/page.css +143 -153
  138. data/lib/hecks/projections/glossary/page.js +63 -3
  139. data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
  140. data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
  141. data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
  142. data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
  143. data/lib/hecks/projections/glossary/sentences.rb +13 -3
  144. data/lib/hecks/projections/glossary/slugs.rb +4 -1
  145. data/lib/hecks/projections/glossary.rb +2 -1
  146. data/lib/hecks/projections/plain_english.rb +97 -0
  147. data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
  148. data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
  149. data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
  150. data/lib/hecks/projections/rust_vocabulary.rb +14 -6
  151. data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
  152. data/lib/hecks/projections/statements/actions.rb +99 -0
  153. data/lib/hecks/projections/statements/relationships.rb +86 -0
  154. data/lib/hecks/projections/statements.rb +15 -24
  155. data/lib/hecks/projector/exporter/adapters.rb +112 -0
  156. data/lib/hecks/projector/exporter/translations.rb +18 -35
  157. data/lib/hecks/projector/exporter/worlds.rb +103 -0
  158. data/lib/hecks/projector/exporter.rb +5 -1
  159. data/lib/hecks/quality_control/adapters/agent.rb +6 -7
  160. data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
  161. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
  162. data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
  163. data/lib/hecks/runtime/outbox.rb +2 -9
  164. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
  165. data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
  166. data/lib/hecks/runtime/value.rb +13 -0
  167. data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
  168. data/lib/hecks/rust_build/wasm.rb +0 -12
  169. data/lib/hecks/rust_build/write_if_changed.rb +12 -12
  170. data/lib/hecks/rust_format.rb +77 -0
  171. data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
  172. data/lib/hecks/tools/fuzz_sweep.rb +1 -3
  173. data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
  174. data/lib/hecks/tools/translation_audit.rb +8 -10
  175. data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
  176. data/lib/hecks/version.rb +1 -1
  177. data/lib/hecks/vocabulary.rb +2 -1
  178. data/lib/hecks.rb +3 -0
  179. data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
  180. data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
  181. data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json} +1 -1
  182. data/rust/Cargo.toml +40 -0
  183. data/rust/build/Cargo.toml +36 -0
  184. data/rust/build/clippy.toml +4 -0
  185. data/rust/build/src/adapters_pass.rs +196 -0
  186. data/rust/build/src/binding_pass.rs +54 -13
  187. data/rust/build/src/build_artifact.rs +23 -5
  188. data/rust/build/src/cargo_sync.rs +69 -26
  189. data/rust/build/src/lineage_pass.rs +101 -87
  190. data/rust/build/src/main.rs +11 -2
  191. data/rust/build/src/pipeline.rs +80 -19
  192. data/rust/build/src/reserved_names.rs +6 -50
  193. data/rust/build/src/resolve.rs +82 -35
  194. data/rust/build/src/rustfmt.rs +100 -0
  195. data/rust/build/src/seams_pass.rs +85 -16
  196. data/rust/build/src/subprocess.rs +30 -6
  197. data/rust/build/src/tmp.rs +5 -2
  198. data/rust/build/src/translation_pass.rs +72 -22
  199. data/rust/build/src/world_pass.rs +109 -0
  200. data/rust/clippy.toml +4 -0
  201. data/rust/codegen/Cargo.toml +36 -0
  202. data/rust/codegen/clippy.toml +4 -0
  203. data/rust/codegen/src/asks.rs +40 -14
  204. data/rust/codegen/src/attr.rs +2 -2
  205. data/rust/codegen/src/bridging.rs +676 -117
  206. data/rust/codegen/src/commands.rs +979 -373
  207. data/rust/codegen/src/constraints.rs +88 -25
  208. data/rust/codegen/src/dependency_planning.rs +128 -34
  209. data/rust/codegen/src/domain_generator.rs +300 -170
  210. data/rust/codegen/src/exemplar.rs +87 -32
  211. data/rust/codegen/src/expr_emitter.rs +54 -6
  212. data/rust/codegen/src/fielded.rs +193 -41
  213. data/rust/codegen/src/hecks_naming.rs +124 -16
  214. data/rust/codegen/src/hecks_naming_tests.rs +81 -0
  215. data/rust/codegen/src/json.rs +80 -44
  216. data/rust/codegen/src/json_codec.rs +585 -149
  217. data/rust/codegen/src/literal.rs +104 -138
  218. data/rust/codegen/src/main.rs +145 -52
  219. data/rust/codegen/src/manifest.rs +76 -14
  220. data/rust/codegen/src/mutations.rs +1212 -239
  221. data/rust/codegen/src/naming.rs +111 -60
  222. data/rust/codegen/src/optional_pass.rs +90 -28
  223. data/rust/codegen/src/ports.rs +5 -4
  224. data/rust/codegen/src/prelude.rs +139 -49
  225. data/rust/codegen/src/queries.rs +601 -226
  226. data/rust/codegen/src/reactions.rs +268 -74
  227. data/rust/codegen/src/read_models.rs +606 -205
  228. data/rust/codegen/src/reference_specs.rs +16 -3
  229. data/rust/codegen/src/registry.rs +128 -79
  230. data/rust/codegen/src/reserved_names.rs +6 -50
  231. data/rust/codegen/src/shared.rs +71 -22
  232. data/rust/codegen/src/sidecars.rs +15 -5
  233. data/rust/codegen/src/skip_reason.rs +8 -2
  234. data/rust/codegen/src/types.rs +306 -108
  235. data/rust/host/Cargo.lock +1 -0
  236. data/rust/host/Cargo.toml +40 -4
  237. data/rust/host/HECKS_RELEASE +1 -1
  238. data/rust/host/clippy.toml +4 -0
  239. data/rust/host/src/api.rs +698 -218
  240. data/rust/host/src/auth.rs +491 -214
  241. data/rust/host/src/b64.rs +91 -0
  242. data/rust/host/src/bin/expr_harness.rs +6 -2
  243. data/rust/host/src/bin/lineage_harness.rs +47 -11
  244. data/rust/host/src/bin/mint_harness.rs +70 -37
  245. data/rust/host/src/bin/validate_harness.rs +16 -8
  246. data/rust/host/src/boot.rs +144 -68
  247. data/rust/host/src/boundary_fuzz/api.rs +130 -32
  248. data/rust/host/src/boundary_fuzz/auth.rs +108 -23
  249. data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
  250. data/rust/host/src/boundary_fuzz/ir.rs +164 -12
  251. data/rust/host/src/boundary_fuzz/server.rs +283 -36
  252. data/rust/host/src/boundary_fuzz/web.rs +56 -8
  253. data/rust/host/src/checkout.rs +78 -32
  254. data/rust/host/src/clock.rs +8 -0
  255. data/rust/host/src/commerce_ir.rs +333 -95
  256. data/rust/host/src/crypto.rs +33 -0
  257. data/rust/host/src/dispatch.rs +990 -331
  258. data/rust/host/src/expr_json.rs +1021 -273
  259. data/rust/host/src/extension.rs +24 -4
  260. data/rust/host/src/field_hints.rs +11 -7
  261. data/rust/host/src/fuzz_support.rs +85 -13
  262. data/rust/host/src/host.rs +100 -0
  263. data/rust/host/src/http.rs +22 -0
  264. data/rust/host/src/ir.rs +326 -16
  265. data/rust/host/src/journal.rs +277 -176
  266. data/rust/host/src/lambda_client.rs +219 -53
  267. data/rust/host/src/lib.rs +16 -4
  268. data/rust/host/src/log.rs +62 -11
  269. data/rust/host/src/mint.rs +1153 -326
  270. data/rust/host/src/needs.rs +185 -44
  271. data/rust/host/src/payments/keystore.rs +197 -45
  272. data/rust/host/src/payments/roles.rs +39 -7
  273. data/rust/host/src/payments/tests.rs +1427 -282
  274. data/rust/host/src/payments.rs +391 -112
  275. data/rust/host/src/platform_signin/attack_tests.rs +5 -5
  276. data/rust/host/src/platform_signin/config.rs +11 -11
  277. data/rust/host/src/platform_signin/cookie.rs +2 -2
  278. data/rust/host/src/platform_signin/extension.rs +2 -1
  279. data/rust/host/src/platform_signin/handler.rs +9 -8
  280. data/rust/host/src/platform_signin/jti.rs +4 -2
  281. data/rust/host/src/platform_signin/jwks.rs +2 -1
  282. data/rust/host/src/platform_signin/jws.rs +2 -37
  283. data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
  284. data/rust/host/src/platform_signin/mode_tests.rs +5 -3
  285. data/rust/host/src/platform_signin/pages.rs +20 -20
  286. data/rust/host/src/platform_signin/session.rs +8 -1
  287. data/rust/host/src/platform_signin/settings.rs +6 -6
  288. data/rust/host/src/platform_signin/strict_json.rs +12 -9
  289. data/rust/host/src/platform_signin/verify.rs +41 -41
  290. data/rust/host/src/platform_signin/wiring_support.rs +13 -18
  291. data/rust/host/src/presentation.rs +207 -63
  292. data/rust/host/src/presentation_write.rs +502 -187
  293. data/rust/host/src/query_step.rs +69 -18
  294. data/rust/host/src/rate_limit.rs +541 -142
  295. data/rust/host/src/reference_transform.rs +77 -25
  296. data/rust/host/src/reference_validate.rs +443 -104
  297. data/rust/host/src/resend.rs +190 -55
  298. data/rust/host/src/secrets.rs +116 -21
  299. data/rust/host/src/server.rs +483 -104
  300. data/rust/host/src/storage_shape.rs +88 -21
  301. data/rust/host/src/test_pg.rs +17 -7
  302. data/rust/host/src/ui_schema.rs +495 -207
  303. data/rust/host/src/wasm_runner.rs +21 -6
  304. data/rust/host/src/web/commerce.rs +101 -23
  305. data/rust/host/src/web/newsletter/cooldown.rs +10 -3
  306. data/rust/host/src/web/newsletter/template.rs +26 -7
  307. data/rust/host/src/web/newsletter/tests.rs +112 -23
  308. data/rust/host/src/web/newsletter.rs +209 -59
  309. data/rust/host/src/web/newsletter_send.rs +251 -79
  310. data/rust/host/src/web/registration_receipt/tests.rs +16 -4
  311. data/rust/host/src/web/registration_receipt.rs +100 -24
  312. data/rust/host/src/web/registrations.rs +1384 -332
  313. data/rust/host/src/web/resend_webhook.rs +138 -80
  314. data/rust/host/src/web.rs +2416 -508
  315. data/rust/lsp/Cargo.toml +36 -0
  316. data/rust/lsp/clippy.toml +4 -0
  317. data/rust/lsp/src/json.rs +29 -22
  318. data/rust/lsp/src/main.rs +81 -31
  319. data/rust/lsp/src/outline.rs +23 -7
  320. data/rust/lsp/src/rpc.rs +17 -4
  321. data/rust/parser/Cargo.toml +36 -0
  322. data/rust/parser/clippy.toml +4 -0
  323. data/rust/parser/src/build/closed_sets.rs +6 -1
  324. data/rust/parser/src/build/identity.rs +4 -3
  325. data/rust/parser/src/build/mod.rs +1 -1
  326. data/rust/parser/src/build/named_pattern.rs +4 -1
  327. data/rust/parser/src/build/naming.rs +2 -80
  328. data/rust/parser/src/build/pattern_subset.rs +6 -8
  329. data/rust/parser/src/build/query_derive.rs +3 -1
  330. data/rust/parser/src/build/query_inference.rs +14 -5
  331. data/rust/parser/src/build/read_model.rs +4 -1
  332. data/rust/parser/src/build/references.rs +4 -3
  333. data/rust/parser/src/canonical.rs +34 -15
  334. data/rust/parser/src/emit.rs +187 -105
  335. data/rust/parser/src/expr/ast_json.rs +134 -42
  336. data/rust/parser/src/expr/evaluator.rs +27 -9
  337. data/rust/parser/src/expr/mod.rs +54 -13
  338. data/rust/parser/src/expr/resolver.rs +299 -92
  339. data/rust/parser/src/ir.rs +35 -3
  340. data/rust/parser/src/keywords.rs +7168 -516
  341. data/rust/parser/src/lex.rs +11 -25
  342. data/rust/parser/src/lib.rs +1 -0
  343. data/rust/parser/src/main.rs +65 -6
  344. data/rust/parser/src/parse/adapter.rs +157 -0
  345. data/rust/parser/src/parse/aggregate.rs +19 -10
  346. data/rust/parser/src/parse/chapter.rs +16 -17
  347. data/rust/parser/src/parse/command.rs +35 -16
  348. data/rust/parser/src/parse/domain_port.rs +4 -7
  349. data/rust/parser/src/parse/entity.rs +21 -11
  350. data/rust/parser/src/parse/hecksagon.rs +10 -4
  351. data/rust/parser/src/parse/lifecycle.rs +9 -5
  352. data/rust/parser/src/parse/mod.rs +22 -91
  353. data/rust/parser/src/parse/needs.rs +21 -12
  354. data/rust/parser/src/parse/policy.rs +2 -2
  355. data/rust/parser/src/parse/process_manager.rs +9 -11
  356. data/rust/parser/src/parse/query.rs +8 -4
  357. data/rust/parser/src/parse/read_model.rs +57 -16
  358. data/rust/parser/src/parse/value_object.rs +7 -2
  359. data/rust/parser/src/parse/world.rs +322 -0
  360. data/rust/parser/src/ruby_value.rs +7 -6
  361. data/rust/src/exemplar/json.rs +4 -4
  362. data/rust/src/exemplar/mutations.rs +18 -0
  363. data/rust/src/exemplar/read_models.rs +1 -10
  364. data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
  365. data/rust/src/kernel/attribute_shapes/list.rs +2 -2
  366. data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
  367. data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
  368. data/rust/src/kernel/cli.rs +229 -112
  369. data/rust/src/kernel/dispatch.rs +323 -115
  370. data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
  371. data/rust/src/kernel/expr.rs +203 -86
  372. data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
  373. data/rust/src/kernel/expression_operators/comparison.rs +87 -2
  374. data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
  375. data/rust/src/kernel/expression_operators/logical.rs +9 -3
  376. data/rust/src/kernel/expression_operators/membership.rs +3 -1
  377. data/rust/src/kernel/expression_operators/mod.rs +34 -21
  378. data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
  379. data/rust/src/kernel/expression_operators/positional.rs +84 -30
  380. data/rust/src/kernel/expression_operators/presence.rs +52 -13
  381. data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
  382. data/rust/src/kernel/expression_operators/sized.rs +24 -8
  383. data/rust/src/kernel/expression_operators/text.rs +120 -27
  384. data/rust/src/kernel/expression_operators/to_string.rs +10 -4
  385. data/rust/src/kernel/json.rs +230 -148
  386. data/rust/src/kernel/mod.rs +46 -33
  387. data/rust/src/kernel/named_query.rs +63 -15
  388. data/rust/src/kernel/needs.rs +112 -25
  389. data/rust/src/kernel/orchestrate.rs +980 -581
  390. data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
  391. data/rust/src/kernel/pattern.rs +107 -34
  392. data/rust/src/kernel/query_comparators.rs +118 -41
  393. data/rust/src/kernel/query_ordering.rs +13 -5
  394. data/rust/src/kernel/read_model.rs +479 -181
  395. data/rust/src/kernel/reference_lookup.rs +122 -26
  396. data/rust/src/kernel/repository.rs +293 -66
  397. data/rust/src/kernel/routing.rs +58 -42
  398. data/rust/src/kernel/ruby_float.rs +139 -0
  399. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
  400. data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
  401. data/rust/src/kernel/vocab/field_hint.rs +19 -25
  402. data/rust/src/kernel/vocab/mod.rs +6 -6
  403. data/rust/src/kernel/vocab/query_comparator.rs +23 -24
  404. data/rust/src/kernel/vocab/refusal_template.rs +501 -297
  405. data/rust/src/lib.rs +50 -0
  406. data/rust/src/main.rs +4 -1
  407. data/rust/web/Cargo.toml +36 -0
  408. data/rust/web/clippy.toml +4 -0
  409. metadata +82 -15
  410. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
  411. data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
  412. data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
  413. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
  414. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
  415. data/lib/hecks/ports/authentication.port +0 -6
  416. data/lib/hecks/ports/authentication.rb +0 -67
  417. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
  418. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
  419. data/rust/host/src/approval.rs +0 -652
@@ -60,7 +60,7 @@ pub struct DispatchSpec {
60
60
  pub with: &'static [(&'static str, WithValue)],
61
61
  /// The command that undoes this dispatch, if any. A reference so the const table can nest it;
62
62
  /// a compensation is never itself compensable.
63
- pub compensates: Option<&'static DispatchSpec>,
63
+ pub compensates: Option<&'static Self>,
64
64
  }
65
65
 
66
66
  /// One leg of a process manager: on `event_type` from `from_state`, move to `to_state` and
@@ -105,7 +105,9 @@ pub struct CompletedCompensation {
105
105
 
106
106
  // Build refuses two legs on one (event, state) pair, so the first match is the only one.
107
107
  fn select_leg<'a>(pm: &'a ProcessManagerDef, event: &str, state: &str) -> Option<&'a Handler> {
108
- pm.handlers.iter().find(|h| h.event_type == event && h.from_state == state)
108
+ pm.handlers
109
+ .iter()
110
+ .find(|h| h.event_type == event && h.from_state == state)
109
111
  }
110
112
 
111
113
  fn leg_mismatch(pm: &ProcessManagerDef, event: &str, state: &str) -> String {
@@ -124,7 +126,11 @@ fn correlation_head(correlates_by: &str) -> &str {
124
126
  }
125
127
 
126
128
  // An empty correlation counts as absent, so callers need only one check.
127
- fn correlation_of(pm: &ProcessManagerDef, event: &Event, reference_key_fn: fn(&str) -> Option<&'static str>) -> Option<String> {
129
+ fn correlation_of(
130
+ pm: &ProcessManagerDef,
131
+ event: &Event,
132
+ reference_key_fn: fn(&str) -> Option<&'static str>,
133
+ ) -> Option<String> {
128
134
  // Tier 1: a dotted path into the event's payload.
129
135
  if let Some(v) = event.payload.dig(pm.correlates_by) {
130
136
  if let Ok(id) = v.to_id_component() {
@@ -153,7 +159,13 @@ fn correlation_of(pm: &ProcessManagerDef, event: &Event, reference_key_fn: fn(&s
153
159
  }
154
160
  }
155
161
 
156
- fn resolve_with(pm: &ProcessManagerDef, value: &WithValue, event: &Event, correlation: &str, memory: &Json) -> Json {
162
+ fn resolve_with(
163
+ pm: &ProcessManagerDef,
164
+ value: &WithValue,
165
+ event: &Event,
166
+ correlation: &str,
167
+ memory: &Json,
168
+ ) -> Json {
157
169
  match value {
158
170
  WithValue::Literal(build) => build(),
159
171
  WithValue::Ref(name) => {
@@ -178,12 +190,30 @@ fn qualify_saga_command_name(domain_name: &str, command_name: &str) -> String {
178
190
  format!("{domain_name}::{}", command_name.replacen("::", ".", 1))
179
191
  }
180
192
 
181
- fn build_dispatch_args(pm: &ProcessManagerDef, spec: &DispatchSpec, event: &Event, correlation: &str, memory: &Json, domain_name: &str, tables: &Tables) -> Json {
182
- let projected = Json::Object(spec.with.iter().map(|(key, value)| (key.to_string(), resolve_with(pm, value, event, correlation, memory))).collect());
193
+ fn build_dispatch_args(
194
+ pm: &ProcessManagerDef,
195
+ spec: &DispatchSpec,
196
+ event: &Event,
197
+ correlation: &str,
198
+ memory: &Json,
199
+ domain_name: &str,
200
+ tables: &Tables,
201
+ ) -> Json {
202
+ let projected = Json::Object(
203
+ spec.with
204
+ .iter()
205
+ .map(|(key, value)| {
206
+ (
207
+ key.to_string(),
208
+ resolve_with(pm, value, event, correlation, memory),
209
+ )
210
+ })
211
+ .collect(),
212
+ );
183
213
  // A saga leg's `with:` is always an explicit projection. The verb is qualified before the
184
214
  // lookup because the tables are keyed by the qualified verb.
185
215
  let qualified = qualify_saga_command_name(domain_name, spec.command_name);
186
- route_dispatch_args(projected, &qualified, tables, event)
216
+ route_dispatch_args(&projected, &qualified, tables, event)
187
217
  }
188
218
 
189
219
  #[derive(Clone, Copy)]
@@ -201,14 +231,65 @@ pub struct Tables<'a> {
201
231
  pub entity_identity_head_fn: fn(&str) -> Option<&'static str>,
202
232
  }
203
233
 
234
+ /// The shape of the generated `dispatch` an orchestration step calls into: the store, the verb,
235
+ /// its arguments, the caller's role and actor, and the mutation log it appends to.
236
+ type DispatchFn<S> = fn(
237
+ &mut S,
238
+ &str,
239
+ &Json,
240
+ Option<&str>,
241
+ Option<&str>,
242
+ &mut Vec<MutationRecord>,
243
+ ) -> Result<Vec<Event>, Refusal>;
244
+
245
+ /// What one cascade threads through every reaction it triggers: the store it dispatches against
246
+ /// and the logs it appends to. `depth` is an argument, not state, because it grows with each
247
+ /// re-entry.
248
+ struct Cascade<'a, S: AggregateScan> {
249
+ store: &'a mut S,
250
+ dispatch_fn: DispatchFn<S>,
251
+ tables: Tables<'static>,
252
+ sagas: &'a mut HashMap<(String, String), SagaInstance>,
253
+ // One host-supplied moment for the whole cascade.
254
+ occurred_at: Option<&'a str>,
255
+ all_events: &'a mut Vec<Event>,
256
+ mutations: &'a mut Vec<MutationRecord>,
257
+ cross_domain: &'a mut Vec<PendingCrossDomainReaction>,
258
+ reaction_log: &'a mut Vec<Json>,
259
+ saga_log: &'a mut Vec<Json>,
260
+ }
261
+
262
+ /// One process-manager instance's leg of a cascade: which manager, which conversation, and the
263
+ /// domain its commands are qualified with.
264
+ struct SagaLeg<'a> {
265
+ pm: &'a ProcessManagerDef,
266
+ correlation: &'a str,
267
+ domain_name: &'a str,
268
+ }
269
+
270
+ impl SagaLeg<'_> {
271
+ fn key(&self) -> (String, String) {
272
+ (self.pm.name.to_string(), self.correlation.to_string())
273
+ }
274
+
275
+ /// The correlation every event this leg's dispatches produce is stamped with.
276
+ fn stamp(&self) -> HashMap<String, String> {
277
+ HashMap::from([(
278
+ correlation_head(self.pm.correlates_by).to_string(),
279
+ self.correlation.to_string(),
280
+ )])
281
+ }
282
+ }
283
+
204
284
  /// Dispatches `verb`, then recursively runs the policies and saga legs its events trigger.
205
285
  /// Only a refusal of the top-level `verb` is returned; reaction refusals are logged and swallowed.
206
286
  ///
207
287
  /// `saga_correlation` stamps every event this call produces; only saga-leg dispatches pass it.
208
- #[allow(clippy::too_many_arguments)]
288
+ // reason: every caller, here and in the generated code, holds default-hasher maps.
289
+ #[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
209
290
  pub fn orchestrate<S: AggregateScan>(
210
291
  store: &mut S,
211
- dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
292
+ dispatch_fn: DispatchFn<S>,
212
293
  tables: Tables<'static>,
213
294
  sagas: &mut HashMap<(String, String), SagaInstance>,
214
295
  verb: &str,
@@ -226,41 +307,84 @@ pub fn orchestrate<S: AggregateScan>(
226
307
  reaction_log: &mut Vec<Json>,
227
308
  saga_log: &mut Vec<Json>,
228
309
  ) -> Result<(), Refusal> {
229
- // A fact the command `needs` is answered ahead of every gate (ADR 0081), for a reaction's
230
- // dispatch as for the outermost one.
231
- let enriched = super::needs::enrich(verb, args, occurred_at);
232
- let args = enriched.as_ref().unwrap_or(args);
233
- let mut events = dispatch_fn(store, verb, args, caller_role, caller_actor_id, mutations)?;
310
+ Cascade {
311
+ store,
312
+ dispatch_fn,
313
+ tables,
314
+ sagas,
315
+ occurred_at,
316
+ all_events,
317
+ mutations,
318
+ cross_domain,
319
+ reaction_log,
320
+ saga_log,
321
+ }
322
+ .run(
323
+ verb,
324
+ args,
325
+ caller_role,
326
+ caller_actor_id,
327
+ saga_correlation,
328
+ depth,
329
+ )
330
+ }
234
331
 
235
- if let Some(stamp) = saga_correlation {
236
- for event in &mut events {
237
- event.correlation.get_or_insert_with(HashMap::new).extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
332
+ impl<S: AggregateScan> Cascade<'_, S> {
333
+ fn run(
334
+ &mut self,
335
+ verb: &str,
336
+ args: &Json,
337
+ caller_role: Option<&str>,
338
+ caller_actor_id: Option<&str>,
339
+ saga_correlation: Option<&HashMap<String, String>>,
340
+ depth: usize,
341
+ ) -> Result<(), Refusal> {
342
+ // A fact the command `needs` is answered ahead of every gate (ADR 0081), for a reaction's
343
+ // dispatch as for the outermost one.
344
+ let enriched = super::needs::enrich(verb, args, self.occurred_at);
345
+ let args = enriched.as_ref().unwrap_or(args);
346
+ let mut events = (self.dispatch_fn)(
347
+ self.store,
348
+ verb,
349
+ args,
350
+ caller_role,
351
+ caller_actor_id,
352
+ self.mutations,
353
+ )?;
354
+
355
+ if let Some(stamp) = saga_correlation {
356
+ for event in &mut events {
357
+ event
358
+ .correlation
359
+ .get_or_insert_with(HashMap::new)
360
+ .extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
361
+ }
238
362
  }
239
- }
240
363
 
241
- if let Some(ts) = occurred_at {
242
- for event in &mut events {
243
- event.occurred_at = Some(ts.to_string());
364
+ if let Some(ts) = self.occurred_at {
365
+ for event in &mut events {
366
+ event.occurred_at = Some(ts.to_string());
367
+ }
244
368
  }
245
- }
246
369
 
247
- // Log the whole batch before any reaction runs, so a later event never logs after an
248
- // earlier event's reactions.
249
- for event in &events {
250
- all_events.push(event.clone());
251
- }
370
+ // Log the whole batch before any reaction runs, so a later event never logs after an
371
+ // earlier event's reactions.
372
+ for event in &events {
373
+ self.all_events.push(event.clone());
374
+ }
252
375
 
253
- // Then per event in `emits` order: its policies, then its sagas.
254
- for event in events {
255
- // Saga begin/advance/end run for every event regardless of depth; only the re-entry is
256
- // depth-gated, inside `react_policies` and `deliver_saga_dispatch`.
257
- react_policies(store, dispatch_fn, tables, sagas, &event, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
258
- begin_saga(tables, sagas, &event, saga_log);
259
- advance_saga(store, dispatch_fn, tables, sagas, &event, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
260
- end_saga(tables, sagas, &event, saga_log);
261
- }
376
+ // Then per event in `emits` order: its policies, then its sagas.
377
+ for event in events {
378
+ // Saga begin/advance/end run for every event regardless of depth; only the re-entry is
379
+ // depth-gated, inside `react_policies` and `deliver_saga_dispatch`.
380
+ self.react_policies(&event, depth);
381
+ self.begin_saga(&event);
382
+ self.advance_saga(&event, depth);
383
+ self.end_saga(&event);
384
+ }
262
385
 
263
- Ok(())
386
+ Ok(())
387
+ }
264
388
  }
265
389
 
266
390
  // Decodes a rendered `with:` literal. Wire text keeps its quote marks, so passing it through
@@ -321,13 +445,22 @@ fn offer_row_fields(source: &mut Vec<(String, Json)>, row_id: &str, record: &Jso
321
445
 
322
446
  fn where_holds(where_expr: Option<fn() -> Expr>, event: &Event) -> bool {
323
447
  let Some(build) = where_expr else { return true };
324
- let ctx = EvalContext { args: &event.payload, instance: &NoFields };
448
+ let ctx = EvalContext {
449
+ args: &event.payload,
450
+ instance: &NoFields,
451
+ };
325
452
  matches!(interpret(&build(), &ctx), Ok(v) if v.truthy())
326
453
  }
327
454
 
328
455
  // An empty `with_spec` forwards the whole payload. `extra` (a fan-out's row key) is merged into
329
456
  // the source before projection, not onto its result.
330
- fn trigger_args(policy: &PolicyRule, event: &Event, extra: Option<(&str, String)>, target_verb: &str, tables: &Tables) -> Json {
457
+ fn trigger_args(
458
+ policy: &PolicyRule,
459
+ event: &Event,
460
+ extra: Option<(&str, &str)>,
461
+ target_verb: &str,
462
+ tables: &Tables,
463
+ ) -> Json {
331
464
  trigger_args_with_row(policy, event, extra, None, target_verb, tables)
332
465
  }
333
466
 
@@ -342,7 +475,11 @@ fn for_each_query_args(def: &named_query::QueryDef, event: &Event, tables: &Tabl
342
475
  Json::Object(pairs) => pairs.clone(),
343
476
  _ => Vec::new(),
344
477
  };
345
- let reads = |head: &str| def.conditions.iter().any(|c| matches!(c.value, named_query::QueryConditionValue::Arg(name) if name == head));
478
+ let reads = |head: &str| {
479
+ def.conditions
480
+ .iter()
481
+ .any(|c| matches!(c.value, named_query::QueryConditionValue::Arg(name) if name == head))
482
+ };
346
483
  if let Some(head) = (tables.identity_head_fn)(&event.aggregate) {
347
484
  if !event.id.is_empty() && reads(head) && !args.iter().any(|(name, _)| name == head) {
348
485
  args.push((head.to_string(), Json::str(event.id.clone())));
@@ -352,7 +489,12 @@ fn for_each_query_args(def: &named_query::QueryDef, event: &Event, tables: &Tabl
352
489
  }
353
490
 
354
491
  fn trigger_args_with_row(
355
- policy: &PolicyRule, event: &Event, extra: Option<(&str, String)>, row: Option<(&str, &Json)>, target_verb: &str, tables: &Tables,
492
+ policy: &PolicyRule,
493
+ event: &Event,
494
+ extra: Option<(&str, &str)>,
495
+ row: Option<(&str, &Json)>,
496
+ target_verb: &str,
497
+ tables: &Tables,
356
498
  ) -> Json {
357
499
  let mut source: Vec<(String, Json)> = match &event.payload {
358
500
  Json::Object(pairs) => pairs.clone(),
@@ -360,7 +502,7 @@ fn trigger_args_with_row(
360
502
  };
361
503
  if let Some((key, id)) = &extra {
362
504
  source.retain(|(name, _)| name != key);
363
- source.push(((*key).to_string(), Json::str(id.clone())));
505
+ source.push(((*key).to_string(), Json::str((*id).to_string())));
364
506
  }
365
507
 
366
508
  if policy.with_spec.is_empty() {
@@ -373,7 +515,10 @@ fn trigger_args_with_row(
373
515
  // The lifted receiver replaces any `to` the payload carried; that fact never
374
516
  // reaches the target.
375
517
  source.retain(|(name, _)| name != "to");
376
- return Json::obj(vec![("to", Json::str(event.id.clone())), ("with", Json::Object(source))]);
518
+ return Json::obj(vec![
519
+ ("to", Json::str(event.id.clone())),
520
+ ("with", Json::Object(source)),
521
+ ]);
377
522
  }
378
523
  }
379
524
  }
@@ -396,14 +541,15 @@ fn trigger_args_with_row(
396
541
  .with_spec
397
542
  .iter()
398
543
  .map(|(name, binding)| {
399
- let value = match binding.strip_prefix(':') {
400
- Some(field) => source
401
- .iter()
402
- .find(|(held, _)| held == field)
403
- .map(|(_, held)| held.clone())
404
- .unwrap_or(Json::Null),
405
- None => read_literal_wire(binding),
406
- };
544
+ let value = binding.strip_prefix(':').map_or_else(
545
+ || read_literal_wire(binding),
546
+ |field| {
547
+ source
548
+ .iter()
549
+ .find(|(held, _)| held == field)
550
+ .map_or(Json::Null, |(_, held)| held.clone())
551
+ },
552
+ );
407
553
  ((*name).to_string(), value)
408
554
  })
409
555
  .collect();
@@ -413,11 +559,15 @@ fn trigger_args_with_row(
413
559
  // non-creating target on the same aggregate. Projected facts, even a `to`, stay facts.
414
560
  if extra.is_none() && !(tables.command_creates_fn)(target_verb) {
415
561
  if let Json::Object(pairs) = &routed {
416
- let already_routed = pairs.iter().any(|(k, _)| k == "to") && pairs.iter().any(|(k, _)| k == "with");
562
+ let already_routed =
563
+ pairs.iter().any(|(k, _)| k == "to") && pairs.iter().any(|(k, _)| k == "with");
417
564
  if !already_routed {
418
565
  if let Some(aggregate_name) = target_verb.rsplit_once('.').map(|(agg, _)| agg) {
419
566
  if aggregate_name == event.aggregate {
420
- return Json::obj(vec![("to", Json::str(event.id.clone())), ("with", routed)]);
567
+ return Json::obj(vec![
568
+ ("to", Json::str(event.id.clone())),
569
+ ("with", routed),
570
+ ]);
421
571
  }
422
572
  }
423
573
  }
@@ -430,9 +580,15 @@ fn trigger_args_with_row(
430
580
  // identity. Composite identities and targets the tables know nothing about stay flat: unknown
431
581
  // means don't route. `facts` slices the original pairs to the command's declared attributes.
432
582
  fn split_routed_args(projected: Json, target_verb: &str, tables: &Tables) -> Json {
433
- let Json::Object(pairs) = projected else { return projected };
583
+ let Json::Object(pairs) = projected else {
584
+ return projected;
585
+ };
434
586
  let declared = (tables.command_attributes_fn)(target_verb);
435
- let facts: Vec<(String, Json)> = pairs.iter().filter(|(k, _)| declared.contains(&k.as_str())).cloned().collect();
587
+ let facts: Vec<(String, Json)> = pairs
588
+ .iter()
589
+ .filter(|(k, _)| declared.contains(&k.as_str()))
590
+ .cloned()
591
+ .collect();
436
592
 
437
593
  if (tables.command_creates_fn)(target_verb) {
438
594
  return Json::Object(facts);
@@ -442,12 +598,17 @@ fn split_routed_args(projected: Json, target_verb: &str, tables: &Tables) -> Jso
442
598
  return Json::Object(facts);
443
599
  };
444
600
 
445
- let candidates = [(tables.identity_head_fn)(aggregate_name), (tables.reference_key_fn)(aggregate_name)];
601
+ let candidates = [
602
+ (tables.identity_head_fn)(aggregate_name),
603
+ (tables.reference_key_fn)(aggregate_name),
604
+ ];
446
605
  for key in candidates.into_iter().flatten() {
447
606
  if let Some((_, raw_id)) = pairs.iter().find(|(k, _)| k == key) {
448
607
  // A value-object identity arrives as `{"value": ...}`; `resolved_id_component`
449
608
  // unwraps it.
450
- let Some(id) = resolved_id_component(raw_id) else { continue };
609
+ let Some(id) = resolved_id_component(raw_id) else {
610
+ continue;
611
+ };
451
612
  return Json::obj(vec![("to", Json::str(id)), ("with", Json::Object(facts))]);
452
613
  }
453
614
  }
@@ -467,543 +628,591 @@ fn entity_command_paths(target_verb: &str) -> Option<(&str, String)> {
467
628
 
468
629
  // A value-object field is stored as `{"value": ...}`: try the value as-is, then its `value` once.
469
630
  fn resolved_id_component(v: &Json) -> Option<String> {
470
- v.to_id_component().ok().or_else(|| v.get("value").and_then(|inner| inner.to_id_component().ok())).filter(|id| !id.is_empty())
631
+ v.to_id_component()
632
+ .ok()
633
+ .or_else(|| {
634
+ v.get("value")
635
+ .and_then(|inner| inner.to_id_component().ok())
636
+ })
637
+ .filter(|id| !id.is_empty())
471
638
  }
472
639
 
473
640
  // Routes a one-level entity command that `split_routed_args` cannot: the parent aggregate's
474
641
  // identity comes from the triggering event, not from the `with:` facts. Every other shape falls
475
642
  // through to `split_routed_args`'s answer.
476
- fn route_dispatch_args(projected: Json, target_verb: &str, tables: &Tables, event: &Event) -> Json {
643
+ fn route_dispatch_args(
644
+ projected: &Json,
645
+ target_verb: &str,
646
+ tables: &Tables,
647
+ event: &Event,
648
+ ) -> Json {
477
649
  let routed = split_routed_args(projected.clone(), target_verb, tables);
478
650
  if matches!(&routed, Json::Object(fields) if fields.iter().any(|(k, _)| k == "to")) {
479
651
  return routed;
480
652
  }
481
653
 
482
- let Some((aggregate_name, entity_qualified)) = entity_command_paths(target_verb) else { return routed };
483
- let Json::Object(pairs) = &projected else { return routed };
654
+ let Some((aggregate_name, entity_qualified)) = entity_command_paths(target_verb) else {
655
+ return routed;
656
+ };
657
+ let Json::Object(pairs) = projected else {
658
+ return routed;
659
+ };
484
660
 
485
661
  // Entity identity: its identity attribute, present in the `with:` facts by that name.
486
- let Some(entity_head) = (tables.entity_identity_head_fn)(&entity_qualified) else { return routed };
487
- let Some(entity_id) = pairs.iter().find(|(k, _)| k == entity_head).and_then(|(_, v)| resolved_id_component(v)) else {
662
+ let Some(entity_head) = (tables.entity_identity_head_fn)(&entity_qualified) else {
663
+ return routed;
664
+ };
665
+ let Some(entity_id) = pairs
666
+ .iter()
667
+ .find(|(k, _)| k == entity_head)
668
+ .and_then(|(_, v)| resolved_id_component(v))
669
+ else {
488
670
  return routed;
489
671
  };
490
672
 
491
673
  // Aggregate identity: a projected identity/reference key, else the triggering event's own id
492
674
  // when it is the same aggregate.
493
- let candidates = [(tables.identity_head_fn)(aggregate_name), (tables.reference_key_fn)(aggregate_name)];
675
+ let candidates = [
676
+ (tables.identity_head_fn)(aggregate_name),
677
+ (tables.reference_key_fn)(aggregate_name),
678
+ ];
494
679
  let aggregate_id = candidates
495
680
  .into_iter()
496
681
  .flatten()
497
- .find_map(|key| pairs.iter().find(|(k, _)| k == key).and_then(|(_, v)| resolved_id_component(v)))
498
- .or_else(|| (event.aggregate == aggregate_name && !event.id.is_empty()).then(|| event.id.clone()));
499
- let Some(aggregate_id) = aggregate_id else { return routed };
682
+ .find_map(|key| {
683
+ pairs
684
+ .iter()
685
+ .find(|(k, _)| k == key)
686
+ .and_then(|(_, v)| resolved_id_component(v))
687
+ })
688
+ .or_else(|| {
689
+ (event.aggregate == aggregate_name && !event.id.is_empty()).then(|| event.id.clone())
690
+ });
691
+ let Some(aggregate_id) = aggregate_id else {
692
+ return routed;
693
+ };
500
694
 
501
695
  let declared = (tables.command_attributes_fn)(target_verb);
502
- let facts: Vec<(String, Json)> = pairs.iter().filter(|(k, _)| declared.contains(&k.as_str())).cloned().collect();
696
+ let facts: Vec<(String, Json)> = pairs
697
+ .iter()
698
+ .filter(|(k, _)| declared.contains(&k.as_str()))
699
+ .cloned()
700
+ .collect();
503
701
  Json::obj(vec![
504
- ("to", Json::obj(vec![("aggregate", Json::str(aggregate_id)), ("entities", Json::Array(vec![Json::str(entity_id)]))])),
702
+ (
703
+ "to",
704
+ Json::obj(vec![
705
+ ("aggregate", Json::str(aggregate_id)),
706
+ ("entities", Json::Array(vec![Json::str(entity_id)])),
707
+ ]),
708
+ ),
505
709
  ("with", Json::Object(facts)),
506
710
  ])
507
711
  }
508
712
 
509
- #[allow(clippy::too_many_arguments)]
510
- fn react_policies<S: AggregateScan>(
511
- store: &mut S,
512
- dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
513
- tables: Tables<'static>,
514
- sagas: &mut HashMap<(String, String), SagaInstance>,
515
- event: &Event,
516
- occurred_at: Option<&str>,
517
- depth: usize,
518
- all_events: &mut Vec<Event>,
519
- mutations: &mut Vec<MutationRecord>,
520
- cross_domain: &mut Vec<PendingCrossDomainReaction>,
521
- reaction_log: &mut Vec<Json>,
522
- saga_log: &mut Vec<Json>,
523
- ) {
524
- let emitting = event.aggregate.rsplit("::").next().unwrap_or(event.aggregate.as_str());
525
-
526
- for policy in tables.policies {
527
- if policy.event_name != event.name {
528
- continue;
529
- }
530
- if let Some(qualifier) = policy.event_qualifier {
531
- if qualifier != emitting {
713
+ impl<S: AggregateScan> Cascade<'_, S> {
714
+ fn react_policies(&mut self, event: &Event, depth: usize) {
715
+ let tables = self.tables;
716
+ let emitting = event
717
+ .aggregate
718
+ .rsplit("::")
719
+ .next()
720
+ .unwrap_or(event.aggregate.as_str());
721
+
722
+ for policy in tables.policies {
723
+ if policy.event_name != event.name {
724
+ continue;
725
+ }
726
+ if let Some(qualifier) = policy.event_qualifier {
727
+ if qualifier != emitting {
728
+ continue;
729
+ }
730
+ }
731
+ if !where_holds(policy.where_expr, event) {
532
732
  continue;
533
733
  }
534
- }
535
- if !where_holds(policy.where_expr, event) {
536
- continue;
537
- }
538
-
539
- let record = |extra: Vec<(&str, Json)>| -> Json {
540
- let mut fields = vec![
541
- ("policy", Json::str(policy.policy_name.to_string())),
542
- ("on", Json::str(event.name.clone())),
543
- ("trigger", Json::str(policy.target_verb.to_string())),
544
- ];
545
- fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
546
- Json::obj(fields)
547
- };
548
734
 
549
- if depth + 1 >= MAX_REACTION_DEPTH {
550
- reaction_log.push(record(vec![
551
- ("delivered", Json::Bool(false)),
552
- ("reason", Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached"))),
553
- ]));
554
- continue;
555
- }
735
+ let record = |extra: Vec<(&str, Json)>| -> Json {
736
+ let mut fields = vec![
737
+ ("policy", Json::str(policy.policy_name.to_string())),
738
+ ("on", Json::str(event.name.clone())),
739
+ ("trigger", Json::str(policy.target_verb.to_string())),
740
+ ];
741
+ fields.extend(extra);
742
+ Json::obj(fields)
743
+ };
556
744
 
557
- // A fan-out dispatches once per row its query answers; the row id is merged into the
558
- // source a `with:` projection reads from.
559
- if let Some(for_each) = policy.for_each {
560
- let Some(def) = named_query::find(tables.queries, for_each) else {
561
- reaction_log.push(record(vec![
745
+ if depth + 1 >= MAX_REACTION_DEPTH {
746
+ self.reaction_log.push(record(vec![
562
747
  ("delivered", Json::Bool(false)),
563
- ("reason", Json::str(format!("no query {for_each}"))),
748
+ (
749
+ "reason",
750
+ Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached")),
751
+ ),
564
752
  ]));
565
753
  continue;
566
- };
567
- // The query reads the event, not the projection, with the emitting record's identity
568
- // lent under its head. No caller role: the policy is system-triggered.
569
- let query_args = for_each_query_args(def, event, &tables);
570
- let rows = match named_query::run(store, def, &query_args, None) {
571
- Ok(rows) => rows,
572
- Err(refusal) => {
573
- reaction_log.push(record(vec![
754
+ }
755
+
756
+ // A fan-out dispatches once per row its query answers; the row id is merged into the
757
+ // source a `with:` projection reads from.
758
+ if let Some(for_each) = policy.for_each {
759
+ let Some(def) = named_query::find(tables.queries, for_each) else {
760
+ self.reaction_log.push(record(vec![
574
761
  ("delivered", Json::Bool(false)),
575
- ("reason", Json::str(refusal.to_string())),
762
+ ("reason", Json::str(format!("no query {for_each}"))),
576
763
  ]));
577
764
  continue;
578
- }
579
- };
580
- for (row_id, row) in rows {
581
- let row_record = |extra: Vec<(&str, Json)>| -> Json {
582
- let mut fields = vec![("for_row", Json::str(row_id.clone()))];
583
- fields.extend(extra.into_iter());
584
- let base = record(vec![]);
585
- match base {
586
- Json::Object(mut pairs) => {
587
- pairs.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
588
- Json::Object(pairs)
589
- }
590
- other => other,
765
+ };
766
+ // The query reads the event, not the projection, with the emitting record's
767
+ // identity lent under its head. No caller role: the policy is system-triggered.
768
+ let query_args = for_each_query_args(def, event, &tables);
769
+ let rows = match named_query::run(self.store, def, &query_args, None) {
770
+ Ok(rows) => rows,
771
+ Err(refusal) => {
772
+ self.reaction_log.push(record(vec![
773
+ ("delivered", Json::Bool(false)),
774
+ ("reason", Json::str(refusal.to_string())),
775
+ ]));
776
+ continue;
591
777
  }
592
778
  };
593
- let extra = policy.for_each_key.map(|key| (key, row_id.clone()));
594
- let args = trigger_args_with_row(policy, event, extra, Some((row_id.as_str(), &row)), policy.target_verb, &tables);
595
- let outcome = orchestrate(
596
- store, dispatch_fn, tables, sagas, policy.target_verb, &args, None, None, None, occurred_at, depth + 1,
597
- all_events, mutations, cross_domain, reaction_log, saga_log,
598
- );
599
- match outcome {
600
- Ok(()) => reaction_log.push(row_record(vec![("delivered", Json::Bool(true))])),
601
- Err(refusal) => reaction_log.push(row_record(vec![
602
- ("delivered", Json::Bool(false)),
603
- ("reason", Json::str(refusal.to_string())),
604
- ])),
779
+ for (row_id, row) in rows {
780
+ let row_record = |extra: Vec<(&str, Json)>| -> Json {
781
+ let mut fields = vec![("for_row", Json::str(row_id.clone()))];
782
+ fields.extend(extra);
783
+ let base = record(vec![]);
784
+ match base {
785
+ Json::Object(mut pairs) => {
786
+ pairs.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
787
+ Json::Object(pairs)
788
+ }
789
+ other => other,
790
+ }
791
+ };
792
+ let extra = policy.for_each_key.map(|key| (key, row_id.as_str()));
793
+ let args = trigger_args_with_row(
794
+ policy,
795
+ event,
796
+ extra,
797
+ Some((row_id.as_str(), &row)),
798
+ policy.target_verb,
799
+ &tables,
800
+ );
801
+ let outcome = self.run(policy.target_verb, &args, None, None, None, depth + 1);
802
+ match outcome {
803
+ Ok(()) => self
804
+ .reaction_log
805
+ .push(row_record(vec![("delivered", Json::Bool(true))])),
806
+ Err(refusal) => self.reaction_log.push(row_record(vec![
807
+ ("delivered", Json::Bool(false)),
808
+ ("reason", Json::str(refusal.to_string())),
809
+ ])),
810
+ }
605
811
  }
812
+ continue;
606
813
  }
607
- continue;
608
- }
609
814
 
610
- // No `with:`: the whole payload forwards verbatim. Reactions are system-triggered, so no
611
- // caller role, and only saga legs stamp a correlation.
612
- let args = trigger_args(policy, event, None, policy.target_verb, &tables);
613
- let outcome = orchestrate(
614
- store, dispatch_fn, tables, sagas, policy.target_verb, &args, None, None, None, occurred_at, depth + 1,
615
- all_events, mutations, cross_domain, reaction_log, saga_log,
616
- );
617
- match outcome {
618
- Ok(()) => reaction_log.push(record(vec![("delivered", Json::Bool(true))])),
619
- // A refusal is recorded, not fatal to the emitting command. A panic is not caught, on
620
- // purpose.
621
- Err(refusal) => reaction_log.push(record(vec![
622
- ("delivered", Json::Bool(false)),
623
- ("reason", Json::str(refusal.to_string())),
624
- ])),
815
+ // No `with:`: the whole payload forwards verbatim. Reactions are system-triggered, so
816
+ // no caller role, and only saga legs stamp a correlation.
817
+ let args = trigger_args(policy, event, None, policy.target_verb, &tables);
818
+ let outcome = self.run(policy.target_verb, &args, None, None, None, depth + 1);
819
+ match outcome {
820
+ Ok(()) => self
821
+ .reaction_log
822
+ .push(record(vec![("delivered", Json::Bool(true))])),
823
+ // A refusal is recorded, not fatal to the emitting command. A panic is not
824
+ // caught, on purpose.
825
+ Err(refusal) => self.reaction_log.push(record(vec![
826
+ ("delivered", Json::Bool(false)),
827
+ ("reason", Json::str(refusal.to_string())),
828
+ ])),
829
+ }
625
830
  }
626
- }
627
831
 
628
- // Cross-domain matches are recorded for the host to deliver, never dispatched or logged here:
629
- // the delivery outcome does not exist yet.
630
- for policy in tables.cross_domain_policies {
631
- if policy.event_name != event.name {
632
- continue;
633
- }
634
- if let Some(qualifier) = policy.event_qualifier {
635
- if qualifier != emitting {
832
+ // Cross-domain matches are recorded for the host to deliver, never dispatched or logged
833
+ // here: the delivery outcome does not exist yet.
834
+ for policy in tables.cross_domain_policies {
835
+ if policy.event_name != event.name {
636
836
  continue;
637
837
  }
838
+ if let Some(qualifier) = policy.event_qualifier {
839
+ if qualifier != emitting {
840
+ continue;
841
+ }
842
+ }
843
+ if !where_holds(policy.where_expr, event) {
844
+ continue;
845
+ }
846
+ self.cross_domain.push(PendingCrossDomainReaction {
847
+ policy_name: policy.policy_name.to_string(),
848
+ event_name: event.name.clone(),
849
+ target_domain: policy.target_domain.to_string(),
850
+ target_verb: policy.target_verb.to_string(),
851
+ payload: event.payload.clone(),
852
+ });
638
853
  }
639
- if !where_holds(policy.where_expr, event) {
640
- continue;
641
- }
642
- cross_domain.push(PendingCrossDomainReaction {
643
- policy_name: policy.policy_name.to_string(),
644
- event_name: event.name.clone(),
645
- target_domain: policy.target_domain.to_string(),
646
- target_verb: policy.target_verb.to_string(),
647
- payload: event.payload.clone(),
648
- });
649
854
  }
650
855
  }
651
856
 
652
- // Silent when `event.name != pm.starts_on` or the instance already exists.
653
- fn begin_saga(tables: Tables<'static>, sagas: &mut HashMap<(String, String), SagaInstance>, event: &Event, saga_log: &mut Vec<Json>) {
654
- for pm in tables.process_managers {
655
- if event.name != pm.starts_on {
656
- continue;
657
- }
857
+ impl<S: AggregateScan> Cascade<'_, S> {
858
+ // Silent when `event.name != pm.starts_on` or the instance already exists.
859
+ fn begin_saga(&mut self, event: &Event) {
860
+ let tables = self.tables;
861
+ for pm in tables.process_managers {
862
+ if event.name != pm.starts_on {
863
+ continue;
864
+ }
865
+
866
+ let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
867
+ self.saga_log.push(Json::obj(vec![
868
+ ("process_manager", Json::str(pm.name.to_string())),
869
+ ("on", Json::str(event.name.clone())),
870
+ ("born", Json::Bool(false)),
871
+ (
872
+ "reason",
873
+ Json::str(format!("no {} in the payload", pm.correlates_by)),
874
+ ),
875
+ ]));
876
+ continue;
877
+ };
658
878
 
659
- let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
660
- saga_log.push(Json::obj(vec![
879
+ let key = (pm.name.to_string(), correlation.clone());
880
+ if self.sagas.contains_key(&key) {
881
+ continue;
882
+ }
883
+
884
+ self.sagas.insert(
885
+ key,
886
+ SagaInstance {
887
+ state: pm.initial_state.to_string(),
888
+ memory: event.payload.clone(),
889
+ completed_compensations: Vec::new(),
890
+ },
891
+ );
892
+ self.saga_log.push(Json::obj(vec![
661
893
  ("process_manager", Json::str(pm.name.to_string())),
662
894
  ("on", Json::str(event.name.clone())),
663
- ("born", Json::Bool(false)),
664
- ("reason", Json::str(format!("no {} in the payload", pm.correlates_by))),
895
+ ("instance", Json::str(correlation)),
896
+ ("born", Json::Bool(true)),
897
+ ("state", Json::str(pm.initial_state.to_string())),
665
898
  ]));
666
- continue;
667
- };
668
-
669
- let key = (pm.name.to_string(), correlation.clone());
670
- if sagas.contains_key(&key) {
671
- continue;
672
899
  }
673
-
674
- sagas.insert(key, SagaInstance { state: pm.initial_state.to_string(), memory: event.payload.clone(), completed_compensations: Vec::new() });
675
- saga_log.push(Json::obj(vec![
676
- ("process_manager", Json::str(pm.name.to_string())),
677
- ("on", Json::str(event.name.clone())),
678
- ("instance", Json::str(correlation)),
679
- ("born", Json::Bool(true)),
680
- ("state", Json::str(pm.initial_state.to_string())),
681
- ]));
682
900
  }
683
- }
684
901
 
685
- // Silent when no handler answers the event or correlation resolves to nothing.
686
- #[allow(clippy::too_many_arguments)]
687
- fn advance_saga<S: AggregateScan>(
688
- store: &mut S,
689
- dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
690
- tables: Tables<'static>,
691
- sagas: &mut HashMap<(String, String), SagaInstance>,
692
- event: &Event,
693
- occurred_at: Option<&str>,
694
- depth: usize,
695
- all_events: &mut Vec<Event>,
696
- mutations: &mut Vec<MutationRecord>,
697
- cross_domain: &mut Vec<PendingCrossDomainReaction>,
698
- reaction_log: &mut Vec<Json>,
699
- saga_log: &mut Vec<Json>,
700
- ) {
701
- for pm in tables.process_managers {
702
- if !pm.handlers.iter().any(|h| h.event_type == event.name) {
703
- continue;
902
+ // Silent when no handler answers the event or correlation resolves to nothing.
903
+ fn advance_saga(&mut self, event: &Event, depth: usize) {
904
+ let tables = self.tables;
905
+ for pm in tables.process_managers {
906
+ if !pm.handlers.iter().any(|h| h.event_type == event.name) {
907
+ continue;
908
+ }
909
+ let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
910
+ continue;
911
+ };
912
+
913
+ let key = (pm.name.to_string(), correlation.clone());
914
+ let record = |extra: Vec<(&str, Json)>| -> Json {
915
+ let mut fields = vec![
916
+ ("process_manager", Json::str(pm.name.to_string())),
917
+ ("on", Json::str(event.name.clone())),
918
+ ("instance", Json::str(correlation.clone())),
919
+ ];
920
+ fields.extend(extra);
921
+ Json::obj(fields)
922
+ };
923
+
924
+ let Some(state) = self.sagas.get(&key).map(|instance| instance.state.clone()) else {
925
+ self.saga_log.push(record(vec![
926
+ ("advanced", Json::Bool(false)),
927
+ (
928
+ "reason",
929
+ Json::str(format!("no conversation remembers {correlation:?}")),
930
+ ),
931
+ ]));
932
+ continue;
933
+ };
934
+ // The leg is chosen by (event, current state); build refuses two on one pair.
935
+ let Some(handler) = select_leg(pm, &event.name, &state) else {
936
+ self.saga_log.push(record(vec![
937
+ ("advanced", Json::Bool(false)),
938
+ ("reason", Json::str(leg_mismatch(pm, &event.name, &state))),
939
+ ]));
940
+ continue;
941
+ };
942
+
943
+ if let Some(slot) = self.sagas.get_mut(&key) {
944
+ slot.state = handler.to_state.to_string();
945
+ }
946
+ self.saga_log.push(record(vec![
947
+ ("advanced", Json::Bool(true)),
948
+ ("from", Json::str(handler.from_state.to_string())),
949
+ ("to", Json::str(handler.to_state.to_string())),
950
+ ]));
951
+
952
+ let memory = self
953
+ .sagas
954
+ .get(&key)
955
+ .map_or(Json::Null, |instance| instance.memory.clone());
956
+ // `event.aggregate` is `{domain}::{aggregate}`, so the domain is read from it.
957
+ let domain_name = event
958
+ .aggregate
959
+ .split("::")
960
+ .next()
961
+ .unwrap_or(&event.aggregate);
962
+ let leg = SagaLeg {
963
+ pm,
964
+ correlation: &correlation,
965
+ domain_name,
966
+ };
967
+ let mut refused = false;
968
+ for spec in handler.dispatches {
969
+ let args = build_dispatch_args(
970
+ pm,
971
+ spec,
972
+ event,
973
+ &correlation,
974
+ &memory,
975
+ domain_name,
976
+ &tables,
977
+ );
978
+ // Resolved before the forward dispatch: the ledger entry is pushed speculatively.
979
+ let compensation_args = spec.compensates.map(|r| {
980
+ build_dispatch_args(pm, r, event, &correlation, &memory, domain_name, &tables)
981
+ });
982
+ let delivered =
983
+ self.deliver_saga_dispatch(&leg, spec, &args, depth, compensation_args);
984
+ if delivered == Some(false) {
985
+ refused = true;
986
+ }
987
+ }
988
+
989
+ if refused {
990
+ self.compensate(&leg, event, &memory, depth);
991
+ }
704
992
  }
705
- let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else { continue };
993
+ }
706
994
 
707
- let key = (pm.name.to_string(), correlation.clone());
995
+ // Returns `Some(delivered)`, or `None` when the depth ceiling stopped the leg before it tried.
996
+ // The compensation is pushed before the forward dispatch runs: a nested refusal re-entering
997
+ // this saga must already see it. It is popped again if this leg's own attempt refuses.
998
+ fn deliver_saga_dispatch(
999
+ &mut self,
1000
+ leg: &SagaLeg,
1001
+ spec: &DispatchSpec,
1002
+ args: &Json,
1003
+ depth: usize,
1004
+ compensation_args: Option<Json>,
1005
+ ) -> Option<bool> {
1006
+ // Ruby logs the unqualified name and qualifies only at dispatch; this mirrors that split.
708
1007
  let record = |extra: Vec<(&str, Json)>| -> Json {
709
1008
  let mut fields = vec![
710
- ("process_manager", Json::str(pm.name.to_string())),
711
- ("on", Json::str(event.name.clone())),
712
- ("instance", Json::str(correlation.clone())),
1009
+ ("process_manager", Json::str(leg.pm.name.to_string())),
1010
+ ("instance", Json::str(leg.correlation.to_string())),
1011
+ ("dispatch", Json::str(spec.command_name.to_string())),
713
1012
  ];
714
- fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
1013
+ fields.extend(extra);
715
1014
  Json::obj(fields)
716
1015
  };
717
1016
 
718
- let Some(state) = sagas.get(&key).map(|instance| instance.state.clone()) else {
719
- saga_log.push(record(vec![
720
- ("advanced", Json::Bool(false)),
721
- ("reason", Json::str(format!("no conversation remembers {correlation:?}"))),
722
- ]));
723
- continue;
724
- };
725
- // The leg is chosen by (event, current state); build refuses two on one pair.
726
- let Some(handler) = select_leg(pm, &event.name, &state) else {
727
- saga_log.push(record(vec![
728
- ("advanced", Json::Bool(false)),
729
- ("reason", Json::str(leg_mismatch(pm, &event.name, &state))),
1017
+ if depth + 1 >= MAX_REACTION_DEPTH {
1018
+ self.saga_log.push(record(vec![
1019
+ ("delivered", Json::Bool(false)),
1020
+ (
1021
+ "reason",
1022
+ Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached")),
1023
+ ),
730
1024
  ]));
731
- continue;
732
- };
733
-
734
- if let Some(slot) = sagas.get_mut(&key) {
735
- slot.state = handler.to_state.to_string();
1025
+ return None;
736
1026
  }
737
- saga_log.push(record(vec![
738
- ("advanced", Json::Bool(true)),
739
- ("from", Json::str(handler.from_state.to_string())),
740
- ("to", Json::str(handler.to_state.to_string())),
741
- ]));
742
1027
 
743
- let memory = sagas.get(&key).map(|instance| instance.memory.clone()).unwrap_or(Json::Null);
744
- // `event.aggregate` is `{domain}::{aggregate}`, so the domain is read from it.
745
- let domain_name = event.aggregate.split("::").next().unwrap_or(&event.aggregate);
746
- let mut refused = false;
747
- for spec in handler.dispatches {
748
- let args = build_dispatch_args(pm, spec, event, &correlation, &memory, domain_name, &tables);
749
- // Resolved before the forward dispatch: the ledger entry is pushed speculatively.
750
- let compensation_args = spec.compensates.map(|r| build_dispatch_args(pm, r, event, &correlation, &memory, domain_name, &tables));
751
- let stamp: HashMap<String, String> = [(correlation_head(pm.correlates_by).to_string(), correlation.clone())].into_iter().collect();
752
- let delivered = deliver_saga_dispatch(
753
- store, dispatch_fn, tables, sagas, pm, spec, domain_name, &args, &correlation, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log, &stamp, compensation_args,
754
- );
755
- if delivered == Some(false) {
756
- refused = true;
1028
+ // `spec.command_name` is bare on the wire, so it is qualified before `dispatch_by_name`.
1029
+ let qualified = qualify_saga_command_name(leg.domain_name, spec.command_name);
1030
+
1031
+ let key = leg.key();
1032
+ let mut compensation_recorded = false;
1033
+ if let (Some(compensates_spec), Some(r_args)) = (spec.compensates, compensation_args) {
1034
+ if let Some(slot) = self.sagas.get_mut(&key) {
1035
+ slot.completed_compensations.push(CompletedCompensation {
1036
+ command_name: compensates_spec.command_name.to_string(),
1037
+ args: r_args,
1038
+ });
1039
+ compensation_recorded = true;
757
1040
  }
758
1041
  }
759
1042
 
760
- if refused {
761
- compensate(store, dispatch_fn, tables, sagas, pm, &key, event, &correlation, &memory, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
1043
+ // System-triggered, so no caller role; `Some(stamp)` stamps every event this dispatch
1044
+ // produces.
1045
+ let outcome = self.run(&qualified, args, None, None, Some(&leg.stamp()), depth + 1);
1046
+ match outcome {
1047
+ Ok(()) => {
1048
+ self.saga_log
1049
+ .push(record(vec![("delivered", Json::Bool(true))]));
1050
+ Some(true)
1051
+ }
1052
+ Err(refusal) => {
1053
+ // This leg failed, so its speculative entry was never earned. `pop` is safe: any
1054
+ // entry pushed after it by a nested call was already drained by `compensate`.
1055
+ if compensation_recorded {
1056
+ if let Some(slot) = self.sagas.get_mut(&key) {
1057
+ slot.completed_compensations.pop();
1058
+ }
1059
+ }
1060
+ self.saga_log.push(record(vec![
1061
+ ("delivered", Json::Bool(false)),
1062
+ ("reason", Json::str(refusal.to_string())),
1063
+ ]));
1064
+ Some(false)
1065
+ }
762
1066
  }
763
1067
  }
764
- }
765
1068
 
766
- // Returns `Some(delivered)`, or `None` when the depth ceiling stopped the leg before it tried.
767
- // The compensation is pushed before the forward dispatch runs: a nested refusal re-entering this
768
- // saga must already see it. It is popped again if this leg's own attempt refuses.
769
- #[allow(clippy::too_many_arguments)]
770
- fn deliver_saga_dispatch<S: AggregateScan>(
771
- store: &mut S,
772
- dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
773
- tables: Tables<'static>,
774
- sagas: &mut HashMap<(String, String), SagaInstance>,
775
- pm: &ProcessManagerDef,
776
- spec: &DispatchSpec,
777
- domain_name: &str,
778
- args: &Json,
779
- correlation: &str,
780
- occurred_at: Option<&str>,
781
- depth: usize,
782
- all_events: &mut Vec<Event>,
783
- mutations: &mut Vec<MutationRecord>,
784
- cross_domain: &mut Vec<PendingCrossDomainReaction>,
785
- reaction_log: &mut Vec<Json>,
786
- saga_log: &mut Vec<Json>,
787
- stamp: &HashMap<String, String>,
788
- compensation_args: Option<Json>,
789
- ) -> Option<bool> {
790
- // Ruby logs the unqualified name and qualifies only at dispatch; this mirrors that split.
791
- let record = |extra: Vec<(&str, Json)>| -> Json {
792
- let mut fields = vec![
793
- ("process_manager", Json::str(pm.name.to_string())),
794
- ("instance", Json::str(correlation.to_string())),
795
- ("dispatch", Json::str(spec.command_name.to_string())),
796
- ];
797
- fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
798
- Json::obj(fields)
799
- };
800
-
801
- if depth + 1 >= MAX_REACTION_DEPTH {
802
- saga_log.push(record(vec![
803
- ("delivered", Json::Bool(false)),
804
- ("reason", Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached"))),
805
- ]));
806
- return None;
807
- }
808
-
809
- // `spec.command_name` is bare on the wire, so it is qualified before `dispatch_by_name`.
810
- let qualified = qualify_saga_command_name(domain_name, spec.command_name);
811
-
812
- let key = (pm.name.to_string(), correlation.to_string());
813
- let mut compensation_recorded = false;
814
- if let (Some(compensates_spec), Some(r_args)) = (spec.compensates, compensation_args) {
815
- if let Some(slot) = sagas.get_mut(&key) {
816
- slot.completed_compensations.push(CompletedCompensation { command_name: compensates_spec.command_name.to_string(), args: r_args });
817
- compensation_recorded = true;
818
- }
819
- }
1069
+ // Fires one ledger entry, newest-first. Args are pre-resolved, so `build_dispatch_args` is
1070
+ // skipped. A compensation that itself refuses is logged with `compensation_failed` and never
1071
+ // re-triggers `compensate`.
1072
+ fn deliver_derived_compensation(
1073
+ &mut self,
1074
+ leg: &SagaLeg,
1075
+ entry: &CompletedCompensation,
1076
+ depth: usize,
1077
+ ) {
1078
+ let record = |extra: Vec<(&str, Json)>| -> Json {
1079
+ let mut fields = vec![
1080
+ ("process_manager", Json::str(leg.pm.name.to_string())),
1081
+ ("instance", Json::str(leg.correlation.to_string())),
1082
+ ("dispatch", Json::str(entry.command_name.clone())),
1083
+ ];
1084
+ fields.extend(extra);
1085
+ Json::obj(fields)
1086
+ };
820
1087
 
821
- // System-triggered, so no caller role; `Some(stamp)` stamps every event this dispatch
822
- // produces.
823
- let outcome = orchestrate(
824
- store, dispatch_fn, tables, sagas, &qualified, args, None, None, Some(stamp), occurred_at, depth + 1,
825
- all_events, mutations, cross_domain, reaction_log, saga_log,
826
- );
827
- match outcome {
828
- Ok(()) => {
829
- saga_log.push(record(vec![("delivered", Json::Bool(true))]));
830
- Some(true)
831
- }
832
- Err(refusal) => {
833
- // This leg failed, so its speculative entry was never earned. `pop` is safe: any
834
- // entry pushed after it by a nested call was already drained by `compensate`.
835
- if compensation_recorded {
836
- if let Some(slot) = sagas.get_mut(&key) {
837
- slot.completed_compensations.pop();
838
- }
839
- }
840
- saga_log.push(record(vec![
1088
+ if depth + 1 >= MAX_REACTION_DEPTH {
1089
+ self.saga_log.push(record(vec![
841
1090
  ("delivered", Json::Bool(false)),
842
- ("reason", Json::str(refusal.to_string())),
1091
+ (
1092
+ "reason",
1093
+ Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached")),
1094
+ ),
1095
+ ("compensation", Json::Bool(true)),
1096
+ ("compensation_failed", Json::Bool(true)),
843
1097
  ]));
844
- Some(false)
1098
+ return;
845
1099
  }
846
- }
847
- }
848
1100
 
849
- // Fires one ledger entry, newest-first. Args are pre-resolved, so `build_dispatch_args` is
850
- // skipped. A compensation that itself refuses is logged with `compensation_failed` and never
851
- // re-triggers `compensate`.
852
- #[allow(clippy::too_many_arguments)]
853
- fn deliver_derived_compensation<S: AggregateScan>(
854
- store: &mut S,
855
- dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
856
- tables: Tables<'static>,
857
- sagas: &mut HashMap<(String, String), SagaInstance>,
858
- pm: &ProcessManagerDef,
859
- entry: &CompletedCompensation,
860
- domain_name: &str,
861
- correlation: &str,
862
- occurred_at: Option<&str>,
863
- depth: usize,
864
- all_events: &mut Vec<Event>,
865
- mutations: &mut Vec<MutationRecord>,
866
- cross_domain: &mut Vec<PendingCrossDomainReaction>,
867
- reaction_log: &mut Vec<Json>,
868
- saga_log: &mut Vec<Json>,
869
- ) {
870
- let record = |extra: Vec<(&str, Json)>| -> Json {
871
- let mut fields = vec![
872
- ("process_manager", Json::str(pm.name.to_string())),
873
- ("instance", Json::str(correlation.to_string())),
874
- ("dispatch", Json::str(entry.command_name.to_string())),
875
- ];
876
- fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
877
- Json::obj(fields)
878
- };
1101
+ // A compensation's `command_name` is bare on the wire, like a forward dispatch's.
1102
+ let qualified = qualify_saga_command_name(leg.domain_name, &entry.command_name);
879
1103
 
880
- if depth + 1 >= MAX_REACTION_DEPTH {
881
- saga_log.push(record(vec![
882
- ("delivered", Json::Bool(false)),
883
- ("reason", Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached"))),
884
- ("compensation", Json::Bool(true)),
885
- ("compensation_failed", Json::Bool(true)),
886
- ]));
887
- return;
1104
+ let outcome = self.run(
1105
+ &qualified,
1106
+ &entry.args,
1107
+ None,
1108
+ None,
1109
+ Some(&leg.stamp()),
1110
+ depth + 1,
1111
+ );
1112
+ match outcome {
1113
+ Ok(()) => self.saga_log.push(record(vec![
1114
+ ("delivered", Json::Bool(true)),
1115
+ ("compensation", Json::Bool(true)),
1116
+ ])),
1117
+ Err(refusal) => self.saga_log.push(record(vec![
1118
+ ("delivered", Json::Bool(false)),
1119
+ ("reason", Json::str(refusal.to_string())),
1120
+ ("compensation", Json::Bool(true)),
1121
+ ("compensation_failed", Json::Bool(true)),
1122
+ ])),
1123
+ }
888
1124
  }
889
1125
 
890
- // A compensation's `command_name` is bare on the wire, like a forward dispatch's.
891
- let qualified = qualify_saga_command_name(domain_name, &entry.command_name);
892
-
893
- let stamp: HashMap<String, String> = [(correlation_head(pm.correlates_by).to_string(), correlation.to_string())].into_iter().collect();
1126
+ // Silent when `event.name != pm.ends_on`, correlation is absent, or no instance existed.
1127
+ fn end_saga(&mut self, event: &Event) {
1128
+ let tables = self.tables;
1129
+ for pm in tables.process_managers {
1130
+ if event.name != pm.ends_on {
1131
+ continue;
1132
+ }
1133
+ let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
1134
+ continue;
1135
+ };
1136
+ let key = (pm.name.to_string(), correlation.clone());
1137
+ if self.sagas.remove(&key).is_none() {
1138
+ continue;
1139
+ }
894
1140
 
895
- let outcome = orchestrate(
896
- store, dispatch_fn, tables, sagas, &qualified, &entry.args, None, None, Some(&stamp), occurred_at, depth + 1,
897
- all_events, mutations, cross_domain, reaction_log, saga_log,
898
- );
899
- match outcome {
900
- Ok(()) => saga_log.push(record(vec![("delivered", Json::Bool(true)), ("compensation", Json::Bool(true))])),
901
- Err(refusal) => saga_log.push(record(vec![
902
- ("delivered", Json::Bool(false)),
903
- ("reason", Json::str(refusal.to_string())),
904
- ("compensation", Json::Bool(true)),
905
- ("compensation_failed", Json::Bool(true)),
906
- ])),
1141
+ self.saga_log.push(Json::obj(vec![
1142
+ ("process_manager", Json::str(pm.name.to_string())),
1143
+ ("on", Json::str(event.name.clone())),
1144
+ ("instance", Json::str(correlation)),
1145
+ ("ended", Json::Bool(true)),
1146
+ ]));
1147
+ }
907
1148
  }
908
- }
909
1149
 
910
- // Silent when `event.name != pm.ends_on`, correlation is absent, or no instance existed.
911
- fn end_saga(tables: Tables<'static>, sagas: &mut HashMap<(String, String), SagaInstance>, event: &Event, saga_log: &mut Vec<Json>) {
912
- for pm in tables.process_managers {
913
- if event.name != pm.ends_on {
914
- continue;
915
- }
916
- let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else { continue };
917
- let key = (pm.name.to_string(), correlation.clone());
918
- if sagas.remove(&key).is_none() {
919
- continue;
1150
+ // The `on :refused` leg, run once against the post-transition state. Its own dispatches never
1151
+ // compensate on failure.
1152
+ fn compensate(&mut self, leg: &SagaLeg, event: &Event, memory: &Json, depth: usize) {
1153
+ let (pm, correlation, domain_name) = (leg.pm, leg.correlation, leg.domain_name);
1154
+ let key = leg.key();
1155
+ let Some(current_state) = self.sagas.get(&key).map(|instance| instance.state.clone())
1156
+ else {
1157
+ return;
1158
+ };
1159
+ // A process manager with no compensating leg is silent; a state no leg answers from is
1160
+ // logged as `advanced: false`.
1161
+ if !pm.handlers.iter().any(|h| h.event_type == REFUSED) {
1162
+ return;
920
1163
  }
921
1164
 
922
- saga_log.push(Json::obj(vec![
923
- ("process_manager", Json::str(pm.name.to_string())),
924
- ("on", Json::str(event.name.clone())),
925
- ("instance", Json::str(correlation)),
926
- ("ended", Json::Bool(true)),
927
- ]));
928
- }
929
- }
1165
+ let record = |extra: Vec<(&str, Json)>| -> Json {
1166
+ let mut fields = vec![
1167
+ ("process_manager", Json::str(pm.name.to_string())),
1168
+ ("on", Json::str(REFUSED.to_string())),
1169
+ ("instance", Json::str(correlation.to_string())),
1170
+ ];
1171
+ fields.extend(extra);
1172
+ Json::obj(fields)
1173
+ };
930
1174
 
931
- // The `on :refused` leg, run once against the post-transition state. Its own dispatches never
932
- // compensate on failure.
933
- #[allow(clippy::too_many_arguments)]
934
- fn compensate<S: AggregateScan>(
935
- store: &mut S,
936
- dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
937
- tables: Tables<'static>,
938
- sagas: &mut HashMap<(String, String), SagaInstance>,
939
- pm: &ProcessManagerDef,
940
- key: &(String, String),
941
- event: &Event,
942
- correlation: &str,
943
- memory: &Json,
944
- occurred_at: Option<&str>,
945
- depth: usize,
946
- all_events: &mut Vec<Event>,
947
- mutations: &mut Vec<MutationRecord>,
948
- cross_domain: &mut Vec<PendingCrossDomainReaction>,
949
- reaction_log: &mut Vec<Json>,
950
- saga_log: &mut Vec<Json>,
951
- ) {
952
- let Some(current_state) = sagas.get(key).map(|instance| instance.state.clone()) else { return };
953
- // A process manager with no compensating leg is silent; a state no leg answers from is logged
954
- // as `advanced: false`.
955
- if !pm.handlers.iter().any(|h| h.event_type == REFUSED) {
956
- return;
957
- }
958
-
959
- let record = |extra: Vec<(&str, Json)>| -> Json {
960
- let mut fields = vec![
961
- ("process_manager", Json::str(pm.name.to_string())),
962
- ("on", Json::str(REFUSED.to_string())),
963
- ("instance", Json::str(correlation.to_string())),
964
- ];
965
- fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
966
- Json::obj(fields)
967
- };
1175
+ let Some(compensation) = select_leg(pm, REFUSED, &current_state) else {
1176
+ self.saga_log.push(record(vec![
1177
+ ("advanced", Json::Bool(false)),
1178
+ (
1179
+ "reason",
1180
+ Json::str(leg_mismatch(pm, REFUSED, &current_state)),
1181
+ ),
1182
+ ]));
1183
+ return;
1184
+ };
968
1185
 
969
- let Some(compensation) = select_leg(pm, REFUSED, &current_state) else {
970
- saga_log.push(record(vec![
971
- ("advanced", Json::Bool(false)),
972
- ("reason", Json::str(leg_mismatch(pm, REFUSED, &current_state))),
1186
+ if let Some(slot) = self.sagas.get_mut(&key) {
1187
+ slot.state = compensation.to_state.to_string();
1188
+ }
1189
+ self.saga_log.push(record(vec![
1190
+ ("advanced", Json::Bool(true)),
1191
+ ("from", Json::str(compensation.from_state.to_string())),
1192
+ ("to", Json::str(compensation.to_state.to_string())),
973
1193
  ]));
974
- return;
975
- };
976
-
977
- if let Some(slot) = sagas.get_mut(key) {
978
- slot.state = compensation.to_state.to_string();
979
- }
980
- saga_log.push(record(vec![
981
- ("advanced", Json::Bool(true)),
982
- ("from", Json::str(compensation.from_state.to_string())),
983
- ("to", Json::str(compensation.to_state.to_string())),
984
- ]));
985
-
986
- let domain_name = event.aggregate.split("::").next().unwrap_or(&event.aggregate);
987
1194
 
988
- // Derived compensations first, newest-first. The ledger is re-fetched each iteration, not
989
- // snapshotted: a nested reaction may push onto it mid-drain. If one removes the instance
990
- // mid-drain, the loop stops early.
991
- loop {
992
- let entry = match sagas.get_mut(key) {
993
- Some(instance) => instance.completed_compensations.pop(),
994
- None => None,
995
- };
996
- let Some(entry) = entry else { break };
997
- deliver_derived_compensation(store, dispatch_fn, tables, sagas, pm, &entry, domain_name, correlation, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
998
- }
1195
+ // Derived compensations first, newest-first. The ledger is re-fetched each iteration, not
1196
+ // snapshotted: a nested reaction may push onto it mid-drain. If one removes the instance
1197
+ // mid-drain, the loop stops early.
1198
+ loop {
1199
+ let entry = match self.sagas.get_mut(&key) {
1200
+ Some(instance) => instance.completed_compensations.pop(),
1201
+ None => None,
1202
+ };
1203
+ let Some(entry) = entry else { break };
1204
+ self.deliver_derived_compensation(leg, &entry, depth);
1205
+ }
999
1206
 
1000
- for spec in compensation.dispatches {
1001
- let args = build_dispatch_args(pm, spec, event, correlation, memory, domain_name, &tables);
1002
- let compensation_args = spec.compensates.map(|r| build_dispatch_args(pm, r, event, correlation, memory, domain_name, &tables));
1003
- let stamp: HashMap<String, String> = [(correlation_head(pm.correlates_by).to_string(), correlation.to_string())].into_iter().collect();
1004
- deliver_saga_dispatch(
1005
- store, dispatch_fn, tables, sagas, pm, spec, domain_name, &args, correlation, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log, &stamp, compensation_args,
1006
- );
1207
+ let tables = self.tables;
1208
+ for spec in compensation.dispatches {
1209
+ let args =
1210
+ build_dispatch_args(pm, spec, event, correlation, memory, domain_name, &tables);
1211
+ let compensation_args = spec.compensates.map(|r| {
1212
+ build_dispatch_args(pm, r, event, correlation, memory, domain_name, &tables)
1213
+ });
1214
+ self.deliver_saga_dispatch(leg, spec, &args, depth, compensation_args);
1215
+ }
1007
1216
  }
1008
1217
  }
1009
1218
 
@@ -1067,10 +1276,16 @@ mod tests {
1067
1276
  id: "thing-1".to_string(),
1068
1277
  payload: Json::Object(vec![]),
1069
1278
  occurred_at: None,
1070
- correlation: Some([("reference".to_string(), "xfer-1".to_string())].into_iter().collect()),
1279
+ correlation: Some(HashMap::from([(
1280
+ "reference".to_string(),
1281
+ "xfer-1".to_string(),
1282
+ )])),
1071
1283
  };
1072
1284
 
1073
- assert_eq!(correlation_of(&pm, &event, no_reference_key), Some("xfer-1".to_string()));
1285
+ assert_eq!(
1286
+ correlation_of(&pm, &event, no_reference_key),
1287
+ Some("xfer-1".to_string())
1288
+ );
1074
1289
  }
1075
1290
 
1076
1291
  #[test]
@@ -1080,12 +1295,24 @@ mod tests {
1080
1295
  name: "SomeEvent".to_string(),
1081
1296
  aggregate: "Domain::Thing".to_string(),
1082
1297
  id: "thing-1".to_string(),
1083
- payload: Json::Object(vec![("reference".to_string(), Json::Object(vec![("value".to_string(), Json::Str("from-payload".to_string()))]))]),
1298
+ payload: Json::Object(vec![(
1299
+ "reference".to_string(),
1300
+ Json::Object(vec![(
1301
+ "value".to_string(),
1302
+ Json::Str("from-payload".to_string()),
1303
+ )]),
1304
+ )]),
1084
1305
  occurred_at: None,
1085
- correlation: Some([("reference".to_string(), "from-stamp".to_string())].into_iter().collect()),
1306
+ correlation: Some(HashMap::from([(
1307
+ "reference".to_string(),
1308
+ "from-stamp".to_string(),
1309
+ )])),
1086
1310
  };
1087
1311
 
1088
- assert_eq!(correlation_of(&pm, &event, no_reference_key), Some("from-payload".to_string()));
1312
+ assert_eq!(
1313
+ correlation_of(&pm, &event, no_reference_key),
1314
+ Some("from-payload".to_string())
1315
+ );
1089
1316
  }
1090
1317
 
1091
1318
  #[test]
@@ -1097,7 +1324,7 @@ mod tests {
1097
1324
  id: "thing-1".to_string(),
1098
1325
  payload: Json::Object(vec![]),
1099
1326
  occurred_at: None,
1100
- correlation: Some([("reference".to_string(), String::new())].into_iter().collect()),
1327
+ correlation: Some(HashMap::from([("reference".to_string(), String::new())])),
1101
1328
  };
1102
1329
 
1103
1330
  assert_eq!(correlation_of(&pm, &event, no_reference_key), None);
@@ -1106,19 +1333,21 @@ mod tests {
1106
1333
  // The stamping step of `orchestrate`, exercised without a real `dispatch_fn`.
1107
1334
  #[test]
1108
1335
  fn stamping_merges_into_an_existing_correlation_map_rather_than_replacing_it() {
1109
- let mut events = vec![
1110
- Event {
1111
- name: "E".to_string(),
1112
- aggregate: "Domain::Thing".to_string(),
1113
- id: "thing-1".to_string(),
1114
- payload: Json::Null,
1115
- occurred_at: None,
1116
- correlation: Some([("already".to_string(), "here".to_string())].into_iter().collect()),
1117
- },
1118
- ];
1119
- let stamp: HashMap<String, String> = [("reference".to_string(), "xfer-1".to_string())].into_iter().collect();
1336
+ let mut events = vec![Event {
1337
+ name: "E".to_string(),
1338
+ aggregate: "Domain::Thing".to_string(),
1339
+ id: "thing-1".to_string(),
1340
+ payload: Json::Null,
1341
+ occurred_at: None,
1342
+ correlation: Some(HashMap::from([("already".to_string(), "here".to_string())])),
1343
+ }];
1344
+ let stamp: HashMap<String, String> =
1345
+ HashMap::from([("reference".to_string(), "xfer-1".to_string())]);
1120
1346
  for event in &mut events {
1121
- event.correlation.get_or_insert_with(HashMap::new).extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
1347
+ event
1348
+ .correlation
1349
+ .get_or_insert_with(HashMap::new)
1350
+ .extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
1122
1351
  }
1123
1352
 
1124
1353
  let correlation = events[0].correlation.as_ref().unwrap();
@@ -1130,8 +1359,22 @@ mod tests {
1130
1359
  #[test]
1131
1360
  fn occurred_at_stamps_every_event_the_same_host_supplied_moment() {
1132
1361
  let mut events = vec![
1133
- Event { name: "A".to_string(), aggregate: "Domain::Thing".to_string(), id: "t1".to_string(), payload: Json::Null, occurred_at: None, correlation: None },
1134
- Event { name: "B".to_string(), aggregate: "Domain::Thing".to_string(), id: "t2".to_string(), payload: Json::Null, occurred_at: None, correlation: None },
1362
+ Event {
1363
+ name: "A".to_string(),
1364
+ aggregate: "Domain::Thing".to_string(),
1365
+ id: "t1".to_string(),
1366
+ payload: Json::Null,
1367
+ occurred_at: None,
1368
+ correlation: None,
1369
+ },
1370
+ Event {
1371
+ name: "B".to_string(),
1372
+ aggregate: "Domain::Thing".to_string(),
1373
+ id: "t2".to_string(),
1374
+ payload: Json::Null,
1375
+ occurred_at: None,
1376
+ correlation: None,
1377
+ },
1135
1378
  ];
1136
1379
  let occurred_at = Some("2026-08-28T00:00:00Z");
1137
1380
 
@@ -1141,13 +1384,26 @@ mod tests {
1141
1384
  }
1142
1385
  }
1143
1386
 
1144
- assert_eq!(events[0].occurred_at, Some("2026-08-28T00:00:00Z".to_string()));
1145
- assert_eq!(events[1].occurred_at, Some("2026-08-28T00:00:00Z".to_string()));
1387
+ assert_eq!(
1388
+ events[0].occurred_at,
1389
+ Some("2026-08-28T00:00:00Z".to_string())
1390
+ );
1391
+ assert_eq!(
1392
+ events[1].occurred_at,
1393
+ Some("2026-08-28T00:00:00Z".to_string())
1394
+ );
1146
1395
  }
1147
1396
 
1148
1397
  #[test]
1149
1398
  fn occurred_at_leaves_events_unstamped_when_no_caller_supplied_one() {
1150
- let mut events = vec![Event { name: "A".to_string(), aggregate: "Domain::Thing".to_string(), id: "t1".to_string(), payload: Json::Null, occurred_at: None, correlation: None }];
1399
+ let mut events = vec![Event {
1400
+ name: "A".to_string(),
1401
+ aggregate: "Domain::Thing".to_string(),
1402
+ id: "t1".to_string(),
1403
+ payload: Json::Null,
1404
+ occurred_at: None,
1405
+ correlation: None,
1406
+ }];
1151
1407
  let occurred_at: Option<&str> = None;
1152
1408
 
1153
1409
  if let Some(ts) = occurred_at {
@@ -1215,32 +1471,58 @@ mod tests {
1215
1471
  }
1216
1472
 
1217
1473
  #[test]
1218
- fn multi_leg_reentrant_saga_fires_completed_compensations_newest_first_on_a_later_legs_refusal() {
1474
+ fn multi_leg_reentrant_saga_fires_completed_compensations_newest_first_on_a_later_legs_refusal()
1475
+ {
1219
1476
  // `DISPATCH_RA`/`RB` are named statics; the forward legs are inline literals because a
1220
1477
  // static's value cannot be moved into an array (`DispatchSpec` is not `Copy`).
1221
- static DISPATCH_RA: DispatchSpec = DispatchSpec { command_name: "RA", with: &[], compensates: None };
1222
- static DISPATCH_RB: DispatchSpec = DispatchSpec { command_name: "RB", with: &[], compensates: None };
1478
+ static DISPATCH_RA: DispatchSpec = DispatchSpec {
1479
+ command_name: "RA",
1480
+ with: &[],
1481
+ compensates: None,
1482
+ };
1483
+ static DISPATCH_RB: DispatchSpec = DispatchSpec {
1484
+ command_name: "RB",
1485
+ with: &[],
1486
+ compensates: None,
1487
+ };
1223
1488
 
1224
1489
  static HANDLERS: &[Handler] = &[
1225
1490
  Handler {
1226
1491
  event_type: "Started",
1227
1492
  from_state: "start",
1228
1493
  to_state: "state1",
1229
- dispatches: &[DispatchSpec { command_name: "A", with: &[], compensates: Some(&DISPATCH_RA) }],
1494
+ dispatches: &[DispatchSpec {
1495
+ command_name: "A",
1496
+ with: &[],
1497
+ compensates: Some(&DISPATCH_RA),
1498
+ }],
1230
1499
  },
1231
1500
  Handler {
1232
1501
  event_type: "AEvent",
1233
1502
  from_state: "state1",
1234
1503
  to_state: "state2",
1235
- dispatches: &[DispatchSpec { command_name: "B", with: &[], compensates: Some(&DISPATCH_RB) }],
1504
+ dispatches: &[DispatchSpec {
1505
+ command_name: "B",
1506
+ with: &[],
1507
+ compensates: Some(&DISPATCH_RB),
1508
+ }],
1236
1509
  },
1237
1510
  Handler {
1238
1511
  event_type: "BEvent",
1239
1512
  from_state: "state2",
1240
1513
  to_state: "state3",
1241
- dispatches: &[DispatchSpec { command_name: "C", with: &[], compensates: None }],
1514
+ dispatches: &[DispatchSpec {
1515
+ command_name: "C",
1516
+ with: &[],
1517
+ compensates: None,
1518
+ }],
1519
+ },
1520
+ Handler {
1521
+ event_type: REFUSED,
1522
+ from_state: "state3",
1523
+ to_state: "compensated",
1524
+ dispatches: &[],
1242
1525
  },
1243
- Handler { event_type: REFUSED, from_state: "state3", to_state: "compensated", dispatches: &[] },
1244
1526
  ];
1245
1527
 
1246
1528
  static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
@@ -1293,12 +1575,23 @@ mod tests {
1293
1575
  &mut reaction_log,
1294
1576
  &mut saga_log,
1295
1577
  );
1296
- assert!(outcome.is_ok(), "top-level Kickoff should not itself refuse: {outcome:?}");
1578
+ assert!(
1579
+ outcome.is_ok(),
1580
+ "top-level Kickoff should not itself refuse: {outcome:?}"
1581
+ );
1297
1582
 
1298
1583
  let key = ("TestMultiLeg".to_string(), "corr-1".to_string());
1299
- let instance = sagas.get(&key).expect("the saga instance should still exist (it never reaches ends_on)");
1300
- assert_eq!(instance.state, "compensated", "the saga should have unwound to the on-:refused leg's own to_state");
1301
- assert!(instance.completed_compensations.is_empty(), "the ledger should be fully drained after compensate runs");
1584
+ let instance = sagas
1585
+ .get(&key)
1586
+ .expect("the saga instance should still exist (it never reaches ends_on)");
1587
+ assert_eq!(
1588
+ instance.state, "compensated",
1589
+ "the saga should have unwound to the on-:refused leg's own to_state"
1590
+ );
1591
+ assert!(
1592
+ instance.completed_compensations.is_empty(),
1593
+ "the ledger should be fully drained after compensate runs"
1594
+ );
1302
1595
 
1303
1596
  let dispatch_entries: Vec<(String, bool, bool)> = saga_log
1304
1597
  .iter()
@@ -1389,13 +1682,19 @@ mod tests {
1389
1682
  // gates see.
1390
1683
  #[test]
1391
1684
  fn a_needed_fact_is_answered_before_the_absent_argument_gate_and_a_supplied_one_is_kept() {
1392
- let table = Json::parse(r#"{"T::Slot.Lease": [{"fact": "now", "type": "Integer"}]}"#).unwrap();
1685
+ let table =
1686
+ Json::parse(r#"{"T::Slot.Lease": [{"fact": "now", "type": "Integer"}]}"#).unwrap();
1393
1687
  crate::kernel::needs::install(Some(&table));
1394
1688
  crate::kernel::needs::fix_clock(Some(77));
1395
1689
 
1396
1690
  let args = Json::obj(vec![("with", Json::obj(vec![("holder", Json::str("a"))]))]);
1397
1691
  assert!(run_strict_now("T::Slot.Lease", &args, None).is_ok());
1398
- SEEN.with(|s| assert_eq!(s.borrow().as_ref().unwrap().get("now"), Some(&Json::int(77))));
1692
+ SEEN.with(|s| {
1693
+ assert_eq!(
1694
+ s.borrow().as_ref().unwrap().get("now"),
1695
+ Some(&Json::int(77))
1696
+ );
1697
+ });
1399
1698
 
1400
1699
  let supplied = Json::obj(vec![("with", Json::obj(vec![("now", Json::int(5))]))]);
1401
1700
  assert!(run_strict_now("T::Slot.Lease", &supplied, None).is_ok());
@@ -1411,6 +1710,7 @@ mod tests {
1411
1710
  // Pins domain-qualifying a same-domain entity command (`Manifest::Slot.Fill`): its leftover
1412
1711
  // `::` looks cross-domain, and an unqualified name would fail as an unknown command. The fake
1413
1712
  // dispatch panics on any verb but the qualified, dot-joined form.
1713
+ #[allow(clippy::unnecessary_wraps)] // reason: the signature is the `DispatchFn` type.
1414
1714
  fn entity_command_test_dispatch(
1415
1715
  _store: &mut MultiLegTestStore,
1416
1716
  verb: &str,
@@ -1438,21 +1738,25 @@ mod tests {
1438
1738
  }]),
1439
1739
  // The qualified, dot-joined shape a generated `registry.rs` arm uses.
1440
1740
  "Test::Manifest.Slot.Fill" => Ok(vec![plain_event("SlotFilled")]),
1441
- other => panic!("unexpected verb in entity-command qualification test (BUG#9): {other}"),
1741
+ other => {
1742
+ panic!("unexpected verb in entity-command qualification test (BUG#9): {other}")
1743
+ }
1442
1744
  }
1443
1745
  }
1444
1746
 
1445
1747
  #[test]
1446
1748
  fn a_saga_leg_dispatching_a_same_domain_entity_command_is_domain_qualified_and_dot_folded() {
1447
- static HANDLERS: &[Handler] = &[
1448
- Handler {
1449
- event_type: "Started",
1450
- from_state: "start",
1451
- to_state: "filling",
1452
- // Bare on the wire, entity-nested.
1453
- dispatches: &[DispatchSpec { command_name: "Manifest::Slot.Fill", with: &[], compensates: None }],
1454
- },
1455
- ];
1749
+ static HANDLERS: &[Handler] = &[Handler {
1750
+ event_type: "Started",
1751
+ from_state: "start",
1752
+ to_state: "filling",
1753
+ // Bare on the wire, entity-nested.
1754
+ dispatches: &[DispatchSpec {
1755
+ command_name: "Manifest::Slot.Fill",
1756
+ with: &[],
1757
+ compensates: None,
1758
+ }],
1759
+ }];
1456
1760
 
1457
1761
  static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
1458
1762
  name: "TestPacking",
@@ -1504,7 +1808,10 @@ mod tests {
1504
1808
  &mut reaction_log,
1505
1809
  &mut saga_log,
1506
1810
  );
1507
- assert!(outcome.is_ok(), "top-level Kickoff should not itself refuse: {outcome:?}");
1811
+ assert!(
1812
+ outcome.is_ok(),
1813
+ "top-level Kickoff should not itself refuse: {outcome:?}"
1814
+ );
1508
1815
 
1509
1816
  let dispatch_entries: Vec<(String, bool)> = saga_log
1510
1817
  .iter()
@@ -1526,11 +1833,23 @@ mod tests {
1526
1833
 
1527
1834
  #[test]
1528
1835
  fn qualify_saga_command_name_folds_a_same_domain_entity_command_reference() {
1529
- assert_eq!(qualify_saga_command_name("Waybill", "Manifest::Slot.Fill"), "Waybill::Manifest.Slot.Fill");
1530
- assert_eq!(qualify_saga_command_name("Waybill", "Manifest::Slot.Clear"), "Waybill::Manifest.Slot.Clear");
1836
+ assert_eq!(
1837
+ qualify_saga_command_name("Waybill", "Manifest::Slot.Fill"),
1838
+ "Waybill::Manifest.Slot.Fill"
1839
+ );
1840
+ assert_eq!(
1841
+ qualify_saga_command_name("Waybill", "Manifest::Slot.Clear"),
1842
+ "Waybill::Manifest.Slot.Clear"
1843
+ );
1531
1844
  // A plain aggregate command is only domain-prefixed.
1532
- assert_eq!(qualify_saga_command_name("Waybill", "Manifest.Open"), "Waybill::Manifest.Open");
1533
- assert_eq!(qualify_saga_command_name("Waybill", "Manifest.AddSlot"), "Waybill::Manifest.AddSlot");
1845
+ assert_eq!(
1846
+ qualify_saga_command_name("Waybill", "Manifest.Open"),
1847
+ "Waybill::Manifest.Open"
1848
+ );
1849
+ assert_eq!(
1850
+ qualify_saga_command_name("Waybill", "Manifest.AddSlot"),
1851
+ "Waybill::Manifest.AddSlot"
1852
+ );
1534
1853
  }
1535
1854
 
1536
1855
  // Fixture: non-creating `Widgets::Ledger.Grant`, identity head `email`, declared attribute
@@ -1569,38 +1888,67 @@ mod tests {
1569
1888
  }
1570
1889
 
1571
1890
  fn routed_to_a_receiver(id: &str) -> Json {
1572
- Json::obj(vec![("to", Json::str(id)), ("with", Json::obj(vec![("role", Json::str("Admin"))]))])
1891
+ Json::obj(vec![
1892
+ ("to", Json::str(id)),
1893
+ ("with", Json::obj(vec![("role", Json::str("Admin"))])),
1894
+ ])
1573
1895
  }
1574
1896
 
1575
1897
  #[test]
1576
1898
  fn split_routed_args_routes_a_value_object_identity_as_the_receiver() {
1577
1899
  // A value-object identity is `{"value": ...}` in an event payload and forwards as that
1578
1900
  // object.
1579
- let projected = Json::obj(vec![("email", Json::obj(vec![("value", Json::str("a@b.co"))])), ("role", Json::str("Admin"))]);
1901
+ let projected = Json::obj(vec![
1902
+ ("email", Json::obj(vec![("value", Json::str("a@b.co"))])),
1903
+ ("role", Json::str("Admin")),
1904
+ ]);
1580
1905
 
1581
- let routed = split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
1906
+ let routed =
1907
+ split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
1582
1908
 
1583
- assert_eq!(routed, routed_to_a_receiver("a@b.co"), "a VO identity should route as `to` — got {routed:?}");
1909
+ assert_eq!(
1910
+ routed,
1911
+ routed_to_a_receiver("a@b.co"),
1912
+ "a VO identity should route as `to` — got {routed:?}"
1913
+ );
1584
1914
  }
1585
1915
 
1586
1916
  #[test]
1587
1917
  fn split_routed_args_still_routes_a_bare_scalar_identity() {
1588
- let projected = Json::obj(vec![("email", Json::str("a@b.co")), ("role", Json::str("Admin"))]);
1918
+ let projected = Json::obj(vec![
1919
+ ("email", Json::str("a@b.co")),
1920
+ ("role", Json::str("Admin")),
1921
+ ]);
1589
1922
 
1590
- let routed = split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
1923
+ let routed =
1924
+ split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
1591
1925
 
1592
- assert_eq!(routed, routed_to_a_receiver("a@b.co"), "a scalar identity should route as `to` — got {routed:?}");
1926
+ assert_eq!(
1927
+ routed,
1928
+ routed_to_a_receiver("a@b.co"),
1929
+ "a scalar identity should route as `to` — got {routed:?}"
1930
+ );
1593
1931
  }
1594
1932
 
1595
1933
  #[test]
1596
1934
  fn split_routed_args_leaves_the_args_flat_when_the_identity_is_empty_or_not_a_value() {
1597
1935
  let flat = Json::obj(vec![("role", Json::str("Admin"))]);
1598
- let empty = Json::obj(vec![("email", Json::obj(vec![("value", Json::str(""))])), ("role", Json::str("Admin"))]);
1599
- let unrelated = Json::obj(vec![("email", Json::obj(vec![("other", Json::str("a@b.co"))])), ("role", Json::str("Admin"))]);
1936
+ let empty = Json::obj(vec![
1937
+ ("email", Json::obj(vec![("value", Json::str(""))])),
1938
+ ("role", Json::str("Admin")),
1939
+ ]);
1940
+ let unrelated = Json::obj(vec![
1941
+ ("email", Json::obj(vec![("other", Json::str("a@b.co"))])),
1942
+ ("role", Json::str("Admin")),
1943
+ ]);
1600
1944
 
1601
1945
  for projected in [empty, unrelated] {
1602
- let routed = split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
1603
- assert_eq!(routed, flat, "an unusable identity should leave only the declared facts — got {routed:?}");
1946
+ let routed =
1947
+ split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
1948
+ assert_eq!(
1949
+ routed, flat,
1950
+ "an unusable identity should leave only the declared facts — got {routed:?}"
1951
+ );
1604
1952
  }
1605
1953
  }
1606
1954
 
@@ -1612,7 +1960,10 @@ mod tests {
1612
1960
  );
1613
1961
  assert_eq!(entity_command_paths("Waybill::Manifest.AddSlot"), None);
1614
1962
  // A two-level entity command is deliberately not routed.
1615
- assert_eq!(entity_command_paths("Domain::Workspace.Board.Card.Annotate"), None);
1963
+ assert_eq!(
1964
+ entity_command_paths("Domain::Workspace.Board.Card.Annotate"),
1965
+ None
1966
+ );
1616
1967
  }
1617
1968
 
1618
1969
  // Fixture: one-level entity command `Widgets::Crate.Slot.Fill` whose `with:` carries the
@@ -1651,11 +2002,15 @@ mod tests {
1651
2002
  }
1652
2003
 
1653
2004
  #[test]
1654
- fn route_dispatch_args_threads_the_triggering_events_aggregate_identity_into_a_one_level_entity_commands_route() {
2005
+ fn route_dispatch_args_threads_the_triggering_events_aggregate_identity_into_a_one_level_entity_commands_route(
2006
+ ) {
1655
2007
  // `number` is VO-nested; the parent aggregate's identity comes only from the triggering
1656
2008
  // event.
1657
2009
  let tables = bug10_fixture_tables();
1658
- let projected = Json::obj(vec![("number", Json::obj(vec![("value", Json::int(7))])), ("item", Json::str("hello"))]);
2010
+ let projected = Json::obj(vec![
2011
+ ("number", Json::obj(vec![("value", Json::int(7))])),
2012
+ ("item", Json::str("hello")),
2013
+ ]);
1659
2014
  let event = Event {
1660
2015
  name: "SlotAdded".to_string(),
1661
2016
  aggregate: "Widgets::Crate".to_string(),
@@ -1665,7 +2020,7 @@ mod tests {
1665
2020
  correlation: None,
1666
2021
  };
1667
2022
 
1668
- let routed = route_dispatch_args(projected, "Widgets::Crate.Slot.Fill", &tables, &event);
2023
+ let routed = route_dispatch_args(&projected, "Widgets::Crate.Slot.Fill", &tables, &event);
1669
2024
  assert_eq!(
1670
2025
  routed,
1671
2026
  Json::obj(vec![
@@ -1679,11 +2034,15 @@ mod tests {
1679
2034
  }
1680
2035
 
1681
2036
  #[test]
1682
- fn route_dispatch_args_falls_through_unchanged_when_the_triggering_event_is_a_different_aggregate() {
2037
+ fn route_dispatch_args_falls_through_unchanged_when_the_triggering_event_is_a_different_aggregate(
2038
+ ) {
1683
2039
  // The triggering event is a different aggregate, so no receiver may be invented: falls
1684
2040
  // through unrouted.
1685
2041
  let tables = bug10_fixture_tables();
1686
- let projected = Json::obj(vec![("number", Json::obj(vec![("value", Json::int(7))])), ("item", Json::str("hello"))]);
2042
+ let projected = Json::obj(vec![
2043
+ ("number", Json::obj(vec![("value", Json::int(7))])),
2044
+ ("item", Json::str("hello")),
2045
+ ]);
1687
2046
  let event = Event {
1688
2047
  name: "SomethingElseHappened".to_string(),
1689
2048
  aggregate: "Widgets::OtherThing".to_string(),
@@ -1693,7 +2052,7 @@ mod tests {
1693
2052
  correlation: None,
1694
2053
  };
1695
2054
 
1696
- let routed = route_dispatch_args(projected, "Widgets::Crate.Slot.Fill", &tables, &event);
2055
+ let routed = route_dispatch_args(&projected, "Widgets::Crate.Slot.Fill", &tables, &event);
1697
2056
  assert_eq!(
1698
2057
  routed,
1699
2058
  Json::obj(vec![("item", Json::str("hello"))]),
@@ -1706,6 +2065,7 @@ mod tests {
1706
2065
 
1707
2066
  // Two legs on one event from different states, each reached exactly when its `from:` is
1708
2067
  // current.
2068
+ #[allow(clippy::unnecessary_wraps)] // reason: the signature is the `DispatchFn` type.
1709
2069
  fn two_leg_test_dispatch(
1710
2070
  _store: &mut MultiLegTestStore,
1711
2071
  verb: &str,
@@ -1730,14 +2090,24 @@ mod tests {
1730
2090
  }
1731
2091
 
1732
2092
  #[test]
1733
- fn a_saga_leg_is_selected_by_event_and_current_state_so_two_legs_on_one_event_are_both_reachable() {
2093
+ fn a_saga_leg_is_selected_by_event_and_current_state_so_two_legs_on_one_event_are_both_reachable(
2094
+ ) {
1734
2095
  static HANDLERS: &[Handler] = &[
1735
- Handler { event_type: "Scanned", from_state: "posted", to_state: "picked_up", dispatches: &[] },
2096
+ Handler {
2097
+ event_type: "Scanned",
2098
+ from_state: "posted",
2099
+ to_state: "picked_up",
2100
+ dispatches: &[],
2101
+ },
1736
2102
  Handler {
1737
2103
  event_type: "Scanned",
1738
2104
  from_state: "picked_up",
1739
2105
  to_state: "handed_over",
1740
- dispatches: &[DispatchSpec { command_name: "Deliver", with: &[], compensates: None }],
2106
+ dispatches: &[DispatchSpec {
2107
+ command_name: "Deliver",
2108
+ with: &[],
2109
+ compensates: None,
2110
+ }],
1741
2111
  },
1742
2112
  ];
1743
2113
  static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
@@ -1799,8 +2169,16 @@ mod tests {
1799
2169
  return None;
1800
2170
  }
1801
2171
  Some((
1802
- entry.get("from").and_then(Json::as_str).unwrap_or("").to_string(),
1803
- entry.get("to").and_then(Json::as_str).unwrap_or("").to_string(),
2172
+ entry
2173
+ .get("from")
2174
+ .and_then(Json::as_str)
2175
+ .unwrap_or("")
2176
+ .to_string(),
2177
+ entry
2178
+ .get("to")
2179
+ .and_then(Json::as_str)
2180
+ .unwrap_or("")
2181
+ .to_string(),
1804
2182
  ))
1805
2183
  })
1806
2184
  .collect();
@@ -1821,6 +2199,7 @@ mod tests {
1821
2199
 
1822
2200
  // One dispatch announces two events: a saga leg answers the first, a policy the second. The
1823
2201
  // whole batch is logged before any reaction, then reactions run per event in emits order.
2202
+ #[allow(clippy::unnecessary_wraps)] // reason: the signature is the `DispatchFn` type.
1824
2203
  fn two_event_test_dispatch(
1825
2204
  _store: &mut MultiLegTestStore,
1826
2205
  verb: &str,
@@ -1852,7 +2231,11 @@ mod tests {
1852
2231
  event_type: "Legged",
1853
2232
  from_state: "started",
1854
2233
  to_state: "logged",
1855
- dispatches: &[DispatchSpec { command_name: "Log", with: &[], compensates: None }],
2234
+ dispatches: &[DispatchSpec {
2235
+ command_name: "Log",
2236
+ with: &[],
2237
+ compensates: None,
2238
+ }],
1856
2239
  }];
1857
2240
  static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
1858
2241
  name: "Route",
@@ -1929,8 +2312,18 @@ mod tests {
1929
2312
  #[test]
1930
2313
  fn a_leg_miss_names_every_from_state_the_event_could_have_answered_from() {
1931
2314
  static HANDLERS: &[Handler] = &[
1932
- Handler { event_type: "Scanned", from_state: "posted", to_state: "picked_up", dispatches: &[] },
1933
- Handler { event_type: "Scanned", from_state: "picked_up", to_state: "handed_over", dispatches: &[] },
2315
+ Handler {
2316
+ event_type: "Scanned",
2317
+ from_state: "posted",
2318
+ to_state: "picked_up",
2319
+ dispatches: &[],
2320
+ },
2321
+ Handler {
2322
+ event_type: "Scanned",
2323
+ from_state: "picked_up",
2324
+ to_state: "handed_over",
2325
+ dispatches: &[],
2326
+ },
1934
2327
  ];
1935
2328
  let pm = ProcessManagerDef {
1936
2329
  name: "Delivery",
@@ -1940,8 +2333,14 @@ mod tests {
1940
2333
  initial_state: "posted",
1941
2334
  handlers: HANDLERS,
1942
2335
  };
1943
- assert_eq!(select_leg(&pm, "Scanned", "picked_up").map(|h| h.to_state), Some("handed_over"));
2336
+ assert_eq!(
2337
+ select_leg(&pm, "Scanned", "picked_up").map(|h| h.to_state),
2338
+ Some("handed_over")
2339
+ );
1944
2340
  assert!(select_leg(&pm, "Scanned", "handed_over").is_none());
1945
- assert_eq!(leg_mismatch(&pm, "Scanned", "handed_over"), "in \"handed_over\", not \"posted\" or \"picked_up\"");
2341
+ assert_eq!(
2342
+ leg_mismatch(&pm, "Scanned", "handed_over"),
2343
+ "in \"handed_over\", not \"posted\" or \"picked_up\""
2344
+ );
1946
2345
  }
1947
2346
  }