hecks 0.2.0 → 0.3.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 (408) hide show
  1. checksums.yaml +5 -5
  2. data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
  3. data/lib/hecks/adapters/driven/claude_code.rb +127 -0
  4. data/lib/hecks/adapters/driven/d1.adapter +12 -0
  5. data/lib/hecks/adapters/driven/d1.rb +403 -0
  6. data/lib/hecks/adapters/driven/folder.adapter +3 -0
  7. data/lib/hecks/adapters/driven/folder.rb +199 -0
  8. data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
  9. data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
  10. data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
  11. data/lib/hecks/adapters/driven/governance_authorization.rb +62 -0
  12. data/lib/hecks/adapters/driven/heki/journal.rb +51 -0
  13. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  14. data/lib/hecks/adapters/driven/heki/snapshot.rb +38 -0
  15. data/lib/hecks/adapters/driven/heki.adapter +4 -0
  16. data/lib/hecks/adapters/driven/heki.rb +148 -0
  17. data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
  18. data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
  19. data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
  20. data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
  21. data/lib/hecks/adapters/driven/lambda.adapter +4 -0
  22. data/lib/hecks/adapters/driven/lambda.rb +131 -0
  23. data/lib/hecks/adapters/driven/memory.adapter +3 -0
  24. data/lib/hecks/adapters/driven/memory.rb +103 -0
  25. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
  26. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
  27. data/lib/hecks/adapters/driven/postgres/codec.rb +80 -0
  28. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +168 -0
  29. data/lib/hecks/adapters/driven/postgres.adapter +5 -0
  30. data/lib/hecks/adapters/driven/postgres.rb +391 -0
  31. data/lib/hecks/adapters/driven/postgres_era/lineage/era_store.rb +171 -0
  32. data/lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb +190 -0
  33. data/lib/hecks/adapters/driven/postgres_era/lineage/head_compiler.rb +429 -0
  34. data/lib/hecks/adapters/driven/postgres_era/lineage/mint_transaction.rb +166 -0
  35. data/lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb +286 -0
  36. data/lib/hecks/adapters/driven/postgres_era/lineage/resumable_backfill.rb +168 -0
  37. data/lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb +163 -0
  38. data/lib/hecks/adapters/driven/postgres_era/lineage/transform_installer.rb +114 -0
  39. data/lib/hecks/adapters/driven/postgres_era/lineage.rb +137 -0
  40. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/coverage_check.rb +89 -0
  41. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb +82 -0
  42. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
  43. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/minter.rb +132 -0
  44. data/lib/hecks/adapters/driven/postgres_era/lineage_manager.rb +74 -0
  45. data/lib/hecks/adapters/driven/postgres_era.adapter +6 -0
  46. data/lib/hecks/adapters/driven/postgres_era.rb +671 -0
  47. data/lib/hecks/adapters/driven/prism.adapter +3 -0
  48. data/lib/hecks/adapters/driven/prism.rb +80 -0
  49. data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
  50. data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
  51. data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
  52. data/lib/hecks/adapters/driven/sqlite/codec.rb +63 -0
  53. data/lib/hecks/adapters/driven/sqlite/projection.rb +105 -0
  54. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +180 -0
  55. data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
  56. data/lib/hecks/adapters/driven/sqlite.rb +285 -0
  57. data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
  58. data/lib/hecks/adapters/driven/system_clock.rb +14 -0
  59. data/lib/hecks/adapters/driven.rb +36 -0
  60. data/lib/hecks/adapters.rb +6 -0
  61. data/lib/hecks/behaviors/dsl.rb +96 -0
  62. data/lib/hecks/behaviors/expectations.rb +280 -0
  63. data/lib/hecks/behaviors/ir.rb +31 -0
  64. data/lib/hecks/behaviors/rspec.rb +42 -0
  65. data/lib/hecks/behaviors/runner.rb +96 -0
  66. data/lib/hecks/behaviors.rb +25 -0
  67. data/lib/hecks/bluebook/aggregate.rb +108 -0
  68. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
  69. data/lib/hecks/bluebook/assembly/build.rb +48 -0
  70. data/lib/hecks/bluebook/assembly/contract.rb +112 -0
  71. data/lib/hecks/bluebook/assembly/contracts.rb +409 -0
  72. data/lib/hecks/bluebook/assembly/marks.rb +227 -0
  73. data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
  74. data/lib/hecks/bluebook/assembly.rb +78 -0
  75. data/lib/hecks/bluebook/attribute.rb +96 -0
  76. data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
  77. data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
  78. data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
  79. data/lib/hecks/bluebook/behaviour/command.rb +116 -0
  80. data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
  81. data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
  82. data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
  83. data/lib/hecks/bluebook/behaviour/lifecycle.rb +51 -0
  84. data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
  85. data/lib/hecks/bluebook/behaviour/process_manager.rb +38 -0
  86. data/lib/hecks/bluebook/behaviour/query.rb +10 -0
  87. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
  88. data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
  89. data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
  90. data/lib/hecks/bluebook/chapter.rb +68 -0
  91. data/lib/hecks/bluebook/command.rb +124 -0
  92. data/lib/hecks/bluebook/domain_port.rb +102 -0
  93. data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
  94. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +906 -0
  95. data/lib/hecks/bluebook/dsl/attribute_collector.rb +327 -0
  96. data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
  97. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1018 -0
  98. data/lib/hecks/bluebook/dsl/command_builder.rb +626 -0
  99. data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
  100. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
  101. data/lib/hecks/bluebook/dsl/entity_builder.rb +305 -0
  102. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
  103. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
  104. data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
  105. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
  106. data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
  107. data/lib/hecks/bluebook/dsl/policy_builder.rb +120 -0
  108. data/lib/hecks/bluebook/dsl/port_builder.rb +30 -0
  109. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
  110. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +207 -0
  111. data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
  112. data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
  113. data/lib/hecks/bluebook/dsl/rule_reference.rb +172 -0
  114. data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
  115. data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
  116. data/lib/hecks/bluebook/dsl/word_gate.rb +221 -0
  117. data/lib/hecks/bluebook/dsl/world_builder.rb +77 -0
  118. data/lib/hecks/bluebook/dsl.rb +45 -0
  119. data/lib/hecks/bluebook/entity.rb +103 -0
  120. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -0
  121. data/lib/hecks/bluebook/expression/evaluator.rb +261 -0
  122. data/lib/hecks/bluebook/expression/projection.json +170 -0
  123. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
  124. data/lib/hecks/bluebook/expression/resolver.rb +630 -0
  125. data/lib/hecks/bluebook/expression.rb +13 -0
  126. data/lib/hecks/bluebook/hexagon.rb +60 -0
  127. data/lib/hecks/bluebook/lifecycle.rb +42 -0
  128. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
  129. data/lib/hecks/bluebook/meta_validator/judge.rb +559 -0
  130. data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
  131. data/lib/hecks/bluebook/meta_validator/port_judge.rb +47 -0
  132. data/lib/hecks/bluebook/meta_validator/readings.rb +350 -0
  133. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +312 -0
  134. data/lib/hecks/bluebook/meta_validator/shapes.rb +246 -0
  135. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
  136. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
  137. data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
  138. data/lib/hecks/bluebook/meta_validator.rb +452 -0
  139. data/lib/hecks/bluebook/model_check.rb +318 -0
  140. data/lib/hecks/bluebook/pattern_subset.rb +120 -0
  141. data/lib/hecks/bluebook/policy.rb +41 -0
  142. data/lib/hecks/bluebook/process_manager.rb +86 -0
  143. data/lib/hecks/bluebook/project_discovery.rb +30 -0
  144. data/lib/hecks/bluebook/project_loader.rb +40 -0
  145. data/lib/hecks/bluebook/project_register.rb +82 -0
  146. data/lib/hecks/bluebook/query.rb +61 -0
  147. data/lib/hecks/bluebook/read_model.rb +109 -0
  148. data/lib/hecks/bluebook/reference.rb +74 -0
  149. data/lib/hecks/bluebook/smoke_test.rb +166 -0
  150. data/lib/hecks/bluebook/synthesizer.rb +95 -0
  151. data/lib/hecks/bluebook/translation.rb +92 -0
  152. data/lib/hecks/bluebook/value_object.rb +58 -0
  153. data/lib/hecks/bluebook.rb +74 -0
  154. data/lib/hecks/codemod.rb +342 -0
  155. data/lib/hecks/construct.rb +71 -0
  156. data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
  157. data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
  158. data/lib/hecks/deploy/oidc.json +18 -0
  159. data/lib/hecks/doc/reference.rb +389 -0
  160. data/lib/hecks/embryonaut_bluebook.rb +75 -0
  161. data/lib/hecks/facade/cli_door.rb +116 -0
  162. data/lib/hecks/facade/cli_runner.rb +190 -0
  163. data/lib/hecks/facade/command_request.rb +105 -0
  164. data/lib/hecks/facade/handle.rb +173 -0
  165. data/lib/hecks/facade/json_door.rb +154 -0
  166. data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
  167. data/lib/hecks/facade/surface/chapter.rb +107 -0
  168. data/lib/hecks/facade/surface.rb +48 -0
  169. data/lib/hecks/facade.rb +44 -0
  170. data/lib/hecks/forms/app.rb +300 -0
  171. data/lib/hecks/forms/command_form_renderer.rb +113 -0
  172. data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
  173. data/lib/hecks/forms/field_renderer.rb +170 -0
  174. data/lib/hecks/forms/field_shape.rb +232 -0
  175. data/lib/hecks/forms/html.rb +53 -0
  176. data/lib/hecks/forms/index_renderer.rb +35 -0
  177. data/lib/hecks/forms/page.rb +157 -0
  178. data/lib/hecks/forms/params.rb +132 -0
  179. data/lib/hecks/forms/query_form_renderer.rb +114 -0
  180. data/lib/hecks/forms/record_renderer.rb +115 -0
  181. data/lib/hecks/forms/record_table.rb +63 -0
  182. data/lib/hecks/forms/reference_options.rb +30 -0
  183. data/lib/hecks/forms/value_object_shape.rb +46 -0
  184. data/lib/hecks/forms.rb +54 -0
  185. data/lib/hecks/fqn.rb +94 -0
  186. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
  187. data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
  188. data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
  189. data/lib/hecks/framework/bluebook/governance.bluebook +130 -0
  190. data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
  191. data/lib/hecks/framework/oidc.json +39 -0
  192. data/lib/hecks/framework.rb +90 -0
  193. data/lib/hecks/freezer.rb +67 -0
  194. data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
  195. data/lib/hecks/fuzzing/isolated_boot.rb +92 -0
  196. data/lib/hecks/fuzzing/properties.rb +1146 -0
  197. data/lib/hecks/fuzzing/replay.rb +635 -0
  198. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
  199. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +54 -0
  200. data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
  201. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +185 -0
  202. data/lib/hecks/fuzzing/sequence_generator.rb +130 -0
  203. data/lib/hecks/fuzzing/value_generator.rb +177 -0
  204. data/lib/hecks/fuzzing.rb +12 -0
  205. data/lib/hecks/grammar/evolve.rb +291 -0
  206. data/lib/hecks/grammar/expression.bluebook +417 -0
  207. data/lib/hecks/grammar/expression_operators.json +1152 -0
  208. data/lib/hecks/grammar/grammar.hecksagon +20 -0
  209. data/lib/hecks/grammar/oidc.json +69 -0
  210. data/lib/hecks/grammar/translation.bluebook +203 -0
  211. data/lib/hecks/grammar.rb +178 -0
  212. data/lib/hecks/ir.rb +126 -0
  213. data/lib/hecks/language/adapter.bluebook +116 -0
  214. data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
  215. data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
  216. data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
  217. data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
  218. data/lib/hecks/language/bluebook/command.bluebook +437 -0
  219. data/lib/hecks/language/bluebook/entity.bluebook +316 -0
  220. data/lib/hecks/language/bluebook/policy.bluebook +172 -0
  221. data/lib/hecks/language/bluebook/process_manager.bluebook +264 -0
  222. data/lib/hecks/language/bluebook/projection.bluebook +267 -0
  223. data/lib/hecks/language/bluebook/query.bluebook +245 -0
  224. data/lib/hecks/language/bluebook/shape.bluebook +292 -0
  225. data/lib/hecks/language/bluebook/syntax.bluebook +447 -0
  226. data/lib/hecks/language/bluebook/vocabulary.bluebook +364 -0
  227. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
  228. data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
  229. data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
  230. data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
  231. data/lib/hecks/language/oidc.json +313 -0
  232. data/lib/hecks/language/port.bluebook +92 -0
  233. data/lib/hecks/language/translation/translation.bluebook +110 -0
  234. data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
  235. data/lib/hecks/language/world/wiring.bluebook +62 -0
  236. data/lib/hecks/language/world/world.bluebook +84 -0
  237. data/lib/hecks/literal.rb +125 -0
  238. data/lib/hecks/naming.rb +121 -0
  239. data/lib/hecks/ports/access_control.port +4 -0
  240. data/lib/hecks/ports/access_control.rb +62 -0
  241. data/lib/hecks/ports/agent/answers.rb +104 -0
  242. data/lib/hecks/ports/agent.port +4 -0
  243. data/lib/hecks/ports/agent.rb +167 -0
  244. data/lib/hecks/ports/authentication.port +4 -0
  245. data/lib/hecks/ports/authentication.rb +50 -0
  246. data/lib/hecks/ports/authorization.port +4 -0
  247. data/lib/hecks/ports/authorization.rb +59 -0
  248. data/lib/hecks/ports/clock.port +4 -0
  249. data/lib/hecks/ports/clock.rb +62 -0
  250. data/lib/hecks/ports/extraction.port +4 -0
  251. data/lib/hecks/ports/extraction.rb +37 -0
  252. data/lib/hecks/ports/identity_assignment.port +4 -0
  253. data/lib/hecks/ports/identity_assignment.rb +45 -0
  254. data/lib/hecks/ports/identity_generation.port +4 -0
  255. data/lib/hecks/ports/identity_generation.rb +49 -0
  256. data/lib/hecks/ports/identity_resolution.port +4 -0
  257. data/lib/hecks/ports/identity_resolution.rb +40 -0
  258. data/lib/hecks/ports/loading.port +4 -0
  259. data/lib/hecks/ports/loading.rb +13 -0
  260. data/lib/hecks/ports/persistence/append_only.rb +102 -0
  261. data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
  262. data/lib/hecks/ports/persistence/execution.rb +18 -0
  263. data/lib/hecks/ports/persistence/lineage.rb +277 -0
  264. data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
  265. data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
  266. data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
  267. data/lib/hecks/ports/persistence.port +4 -0
  268. data/lib/hecks/ports/persistence.rb +39 -0
  269. data/lib/hecks/ports/projection.port +4 -0
  270. data/lib/hecks/ports/projection.rb +76 -0
  271. data/lib/hecks/ports/query/in_memory.rb +60 -0
  272. data/lib/hecks/ports/query/ordering.rb +41 -0
  273. data/lib/hecks/ports/query.rb +36 -0
  274. data/lib/hecks/ports.rb +27 -0
  275. data/lib/hecks/projections/diagrams.rb +413 -0
  276. data/lib/hecks/projections/ir.rb +18 -0
  277. data/lib/hecks/projections/model/deviations.rb +98 -0
  278. data/lib/hecks/projections/model.rb +145 -0
  279. data/lib/hecks/projections/oidc.rb +110 -0
  280. data/lib/hecks/projections/parser_table.rb +159 -0
  281. data/lib/hecks/projections/reference.rb +38 -0
  282. data/lib/hecks/projections/shape.rb +31 -0
  283. data/lib/hecks/projections/statements.rb +110 -0
  284. data/lib/hecks/projections/vocabulary.rb +100 -0
  285. data/lib/hecks/projections.rb +32 -0
  286. data/lib/hecks/projector/cli_projector.rb +437 -0
  287. data/lib/hecks/projector/docs_projector.rb +321 -0
  288. data/lib/hecks/projector/exporter.rb +141 -0
  289. data/lib/hecks/projector/ir_projector.rb +18 -0
  290. data/lib/hecks/projector/narrate_projector.rb +243 -0
  291. data/lib/hecks/projector/target.rb +97 -0
  292. data/lib/hecks/projector.rb +186 -0
  293. data/lib/hecks/query_ir.rb +392 -0
  294. data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
  295. data/lib/hecks/query_specification/common/comparators.rb +25 -0
  296. data/lib/hecks/query_specification/common/comparison.rb +174 -0
  297. data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
  298. data/lib/hecks/query_specification/common/dsl.rb +58 -0
  299. data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
  300. data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
  301. data/lib/hecks/query_specification/common/null_policy.rb +80 -0
  302. data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
  303. data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
  304. data/lib/hecks/query_specification/common/options.rb +32 -0
  305. data/lib/hecks/query_specification/common/order_by.rb +9 -0
  306. data/lib/hecks/query_specification/common/specification.rb +10 -0
  307. data/lib/hecks/query_specification/common/where_clause.rb +9 -0
  308. data/lib/hecks/query_specification/field_path.rb +89 -0
  309. data/lib/hecks/query_specification/hop_path.rb +130 -0
  310. data/lib/hecks/query_specification/read_model/specification.rb +18 -0
  311. data/lib/hecks/query_specification.rb +14 -0
  312. data/lib/hecks/rendering.rb +48 -0
  313. data/lib/hecks/router/namespace_installer.rb +157 -0
  314. data/lib/hecks/router.rb +70 -0
  315. data/lib/hecks/runtime/caller.rb +50 -0
  316. data/lib/hecks/runtime/capability_graph.rb +44 -0
  317. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +124 -0
  318. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +248 -0
  319. data/lib/hecks/runtime/command_interpreter.rb +413 -0
  320. data/lib/hecks/runtime/command_rules/admissibility.rb +307 -0
  321. data/lib/hecks/runtime/command_rules/arithmetic.rb +254 -0
  322. data/lib/hecks/runtime/command_rules/authorization.rb +64 -0
  323. data/lib/hecks/runtime/command_rules/emission.rb +34 -0
  324. data/lib/hecks/runtime/command_rules/references.rb +181 -0
  325. data/lib/hecks/runtime/command_rules.rb +28 -0
  326. data/lib/hecks/runtime/dependency_planning.rb +231 -0
  327. data/lib/hecks/runtime/dispatcher.rb +286 -0
  328. data/lib/hecks/runtime/entity_element.rb +253 -0
  329. data/lib/hecks/runtime/entity_interpreter.rb +228 -0
  330. data/lib/hecks/runtime/era_check.rb +136 -0
  331. data/lib/hecks/runtime/era_guard/shape_diff.rb +128 -0
  332. data/lib/hecks/runtime/era_guard.rb +193 -0
  333. data/lib/hecks/runtime/era_tamper.rb +61 -0
  334. data/lib/hecks/runtime/errors.rb +100 -0
  335. data/lib/hecks/runtime/event.rb +51 -0
  336. data/lib/hecks/runtime/identity.rb +128 -0
  337. data/lib/hecks/runtime/instance.rb +120 -0
  338. data/lib/hecks/runtime/interpreting.rb +69 -0
  339. data/lib/hecks/runtime/loader.rb +143 -0
  340. data/lib/hecks/runtime/policy_interpreter.rb +366 -0
  341. data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
  342. data/lib/hecks/runtime/query_interpreter.rb +254 -0
  343. data/lib/hecks/runtime/reaction_invocation.rb +245 -0
  344. data/lib/hecks/runtime/read_model_interpreter.rb +266 -0
  345. data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
  346. data/lib/hecks/runtime/reference_hop.rb +99 -0
  347. data/lib/hecks/runtime/refusal_wording.rb +124 -0
  348. data/lib/hecks/runtime/registry/saga_persistence.rb +70 -0
  349. data/lib/hecks/runtime/registry/verification.rb +158 -0
  350. data/lib/hecks/runtime/registry.rb +218 -0
  351. data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
  352. data/lib/hecks/runtime/routing.rb +96 -0
  353. data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
  354. data/lib/hecks/runtime/saga_interpreter.rb +301 -0
  355. data/lib/hecks/runtime/storage_shape.rb +120 -0
  356. data/lib/hecks/runtime/tenant_check.rb +84 -0
  357. data/lib/hecks/runtime/tenant_scope.rb +55 -0
  358. data/lib/hecks/runtime/value/admission.rb +112 -0
  359. data/lib/hecks/runtime/value/coercion.rb +500 -0
  360. data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
  361. data/lib/hecks/runtime/value.rb +125 -0
  362. data/lib/hecks/runtime.rb +95 -0
  363. data/lib/hecks/translation/audit/approval_digest.rb +31 -0
  364. data/lib/hecks/translation/audit/layer_one.rb +37 -0
  365. data/lib/hecks/translation/audit/layer_two.rb +77 -0
  366. data/lib/hecks/translation/audit/unfed_report.rb +42 -0
  367. data/lib/hecks/translation/audit.rb +70 -0
  368. data/lib/hecks/translation/reattest.rb +72 -0
  369. data/lib/hecks/translation/rule_compiler.rb +120 -0
  370. data/lib/hecks/translation/scaffold/differ.rb +183 -0
  371. data/lib/hecks/translation/scaffold/renderer.rb +41 -0
  372. data/lib/hecks/translation/scaffold/writer.rb +28 -0
  373. data/lib/hecks/translation/scaffold.rb +30 -0
  374. data/lib/hecks/translation.rb +11 -0
  375. data/lib/hecks/version.rb +16 -0
  376. data/lib/hecks/vocabulary.rb +213 -0
  377. data/lib/hecks.rb +118 -6
  378. metadata +401 -222
  379. data/bin/hecks +0 -7
  380. data/bin/hecks-package +0 -65
  381. data/bin/hecks_console +0 -12
  382. data/bin/hecks_serverless +0 -6
  383. data/lib/cli/build.rb +0 -14
  384. data/lib/cli/command_runner.rb +0 -28
  385. data/lib/cli/console.rb +0 -10
  386. data/lib/cli/generate.rb +0 -37
  387. data/lib/cli/hecks-cli.rb +0 -27
  388. data/lib/cli/test.rb +0 -57
  389. data/lib/console/commands.rb +0 -8
  390. data/lib/console/hecks-console.rb +0 -1
  391. data/lib/packager/README.md +0 -0
  392. data/lib/packager/app_runner.rb +0 -21
  393. data/lib/packager/args.rb +0 -26
  394. data/lib/packager/compatibility/fixnum.rb +0 -6
  395. data/lib/packager/hecks.rb +0 -39
  396. data/lib/packager/query_runner.rb +0 -21
  397. data/lib/packager/resources/Dockerfile +0 -11
  398. data/lib/packager/resources/app_binary +0 -7
  399. data/lib/packager/resources/bundle_config +0 -3
  400. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
  401. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
  402. data/lib/serverless/Domain +0 -32
  403. data/lib/serverless/cli.rb +0 -75
  404. data/lib/serverless/resources/command_name.js +0 -5
  405. data/lib/serverless/resources/environment.js +0 -7
  406. data/lib/serverless/resources/handler.js.tt +0 -28
  407. data/lib/serverless/resources/run_binary.js +0 -22
  408. data/lib/serverless/resources/serverless.yml +0 -20
@@ -0,0 +1,143 @@
1
+ require_relative "dispatcher"
2
+ require_relative "instance"
3
+ require_relative "event"
4
+ require_relative "errors"
5
+ require_relative "../naming"
6
+ require_relative "../adapters/driven/lambda/client"
7
+ require_relative "../ports/persistence/binding_policy"
8
+ require_relative "../ports/persistence/remote_runtime"
9
+
10
+ module Hecks
11
+ module Runtime
12
+ # THE WRITE-SIDE HALF OF LAMBDA ROUTING — `Runtime::Loader.boot`
13
+ # constructs this INSTEAD OF `Dispatcher` when the booted domain's
14
+ # own `.world` declares Lambda routing (see loader.rb's own
15
+ # `dispatcher_class_for`). Matches `Dispatcher`'s public shape
16
+ # closely enough that everything built ON TOP of a dispatcher —
17
+ # `Handle`, `AggregateDoor`, `Facade::Surface` — needs no changes
18
+ # at all: `Handle#run`'s entire contract is
19
+ # `@dispatcher.dispatch("#{fqn}.#{command}", **identity, **args).instance.state`,
20
+ # and both classes answer that identically.
21
+ #
22
+ # READS DELEGATE, WRITES DON'T. `query`/`reference_query` hand off
23
+ # to a REAL `Dispatcher` built over the SAME registry — since that
24
+ # registry's own repositories are ALREADY Lambda-backed
25
+ # (`Adapters::Lambda`, `persisted_by("Lambda")`, Phase 2's other
26
+ # half), the inherited query machinery (`QueryInterpreter`,
27
+ # `Ports::Query::InMemory.execute`) works completely unchanged, no
28
+ # query logic duplicated here. `dispatch` can't delegate the same
29
+ # way: command validation (givens, constraints) has to actually
30
+ # run in Rust for a Lambda-routed domain, not get pre-checked here
31
+ # against incomplete local state and then merely persisted.
32
+ class RemoteDispatcher
33
+ Result = Struct.new(:verb, :instance, :events, keyword_init: true) do
34
+ def id = instance.id
35
+ def state = instance.to_h
36
+ end
37
+
38
+ attr_reader :registry
39
+
40
+ def initialize(registry, region: "us-east-1")
41
+ @registry = registry
42
+ # `File.basename(registry.root)`, not `bluebooks.keys.first` —
43
+ # matches `Adapters::Lambda`'s own function-name resolution
44
+ # exactly (see its own comment on why: one merged Lambda per
45
+ # deploy, not one per attached chapter, and `root` is the one
46
+ # signal every bluebook in this registry shares regardless of
47
+ # which one attached it) — INCLUDING that same adapter's own
48
+ # `ENV["DOMAIN_NAME"]`-first fix: `root` is always `/var/task`
49
+ # inside a deployed Lambda, giving "task" instead of the real
50
+ # domain name (a real, live AccessDeniedException on
51
+ # "hecks-task" caught this).
52
+ @client = Adapters::Lambda::Client.new(domain: ENV["DOMAIN_NAME"] || File.basename(registry.root), region: region)
53
+ # READ-SIDE DELEGATE ONLY (see class comment) — never dispatched
54
+ # through; a real Dispatcher's own `query`/`reference_query`
55
+ # already resolve generically via `registry.repository(...)`,
56
+ # so building one here reuses that instead of duplicating it.
57
+ @local = Dispatcher.new(registry)
58
+ end
59
+
60
+ def dispatch(verb, saga_correlation: nil, **args)
61
+ domain, aggregate_name, = Naming.split_verb(verb) ||
62
+ raise(UnknownVerb,
63
+ RefusalWording.render("UnknownVerb", "not_fully_qualified", verb: verb.inspect))
64
+ aggregate = @registry.bluebook(domain)&.aggregate(aggregate_name) ||
65
+ raise(UnknownVerb,
66
+ RefusalWording.render("UnknownVerb", "no_aggregate", domain: domain, aggregate: aggregate_name.inspect))
67
+
68
+ # NOT EVERY AGGREGATE IN A LAMBDA-ROUTED DOMAIN IS ITSELF
69
+ # LAMBDA-BOUND — Member's real name->email rekey carries a
70
+ # `compute` rule (era_check.rb's own `check_compute_rules!`),
71
+ # which can only ever run against Postgres, permanently. Its
72
+ # OWN `.hecksagon` bind stays "Postgres" even when
73
+ # `dispatched_by("Lambda")` is on for everything else — checked
74
+ # here by real CAPABILITY (`Ports::Persistence::RemoteRuntime`,
75
+ # §1), not by comparing the adapter's own name to the string
76
+ # "Lambda" — a bind resolves to whatever adapter CLASS actually
77
+ # backs it, and only a class shaped like "the real interpreter
78
+ # lives behind a call boundary" forwards here; anything else
79
+ # (Postgres, Memory, any future local adapter) falls through to
80
+ # the real local Dispatcher instead of being forwarded to a
81
+ # Lambda that has no way to represent its lineage history at
82
+ # all.
83
+ adapter_name = Ports::Persistence::BindingPolicy.resolve(@registry, domain, aggregate).adapter
84
+ return @local.dispatch(verb, saga_correlation: saga_correlation, **args) unless @registry.adapter_class(adapter_name) <= Ports::Persistence::RemoteRuntime
85
+
86
+ response = @client.dispatch(verb, args)
87
+
88
+ refusal = response.fetch("refusals", []).find { |r| r["verb"] == verb }
89
+ raise RemoteRefusal, "#{verb} refused: #{refusal['error']}" if refusal
90
+
91
+ # THIS STEP'S OWN mutations — `mutations` is one entry per
92
+ # replayed step (rust/host's rehydrate-and-replay design,
93
+ # Phase 1), so `.last` is exactly the step just dispatched.
94
+ # Matched by fully-qualified aggregate name, not just "the
95
+ # first mutation" — a command whose reaction ALSO mutates a
96
+ # different aggregate (a policy, a saga leg) puts more than
97
+ # one mutation in the same step, and the direct effect of
98
+ # THIS verb is the one this dispatch's own caller expects
99
+ # `.instance` to be.
100
+ fqn = "#{domain}::#{aggregate.hecks_name}"
101
+ mutation = response.fetch("mutations", []).last&.find { |m| m["aggregate"] == fqn } ||
102
+ raise(WiringError,
103
+ "#{verb} was accepted but rust/host reported no mutation for #{fqn} — response: #{response.inspect}")
104
+
105
+ instance = Instance.new(aggregate: aggregate, id: mutation["id"],
106
+ state: JSON.parse(JSON.generate(mutation["state"]), symbolize_names: true))
107
+
108
+ Result.new(verb: verb, instance: instance, events: step_events(response))
109
+ end
110
+
111
+ def query(verb, **args) = @local.query(verb, **args)
112
+ def reference_query(verb, **args) = @local.reference_query(verb, **args)
113
+
114
+ # THE FULL DOMAIN'S EVENT HISTORY, on every call — `{"read":
115
+ # true}` replays the whole journal (Phase 1's `dispatch::read`),
116
+ # so its own `events` array already IS the complete log, the
117
+ # same thing `@registry.event_log` would answer for a local
118
+ # dispatch. Not cached: `AggregateDoor.events`/`Handle#events`
119
+ # are not called in this codebase's own hot paths today: if that
120
+ # changes, caching belongs here, not in every caller.
121
+ def events
122
+ @client.read.fetch("events", []).map { |e| build_event(e) }
123
+ end
124
+
125
+ private
126
+
127
+ def step_events(response)
128
+ response.fetch("events", []).map { |e| build_event(e) }
129
+ end
130
+
131
+ # Rust's own event JSON (kernel/cli.rs's `event_to_json`) carries
132
+ # `name`/`aggregate`/`id`/`payload` only — no `occurred_at`
133
+ # (nothing in the kernel tracks wall-clock time; every
134
+ # replay is deterministic and timestamp-free by design). `nil`
135
+ # here, not a synthesized `Time.now` that would silently lie
136
+ # about when something actually happened.
137
+ def build_event(json)
138
+ Event.new(name: json["name"], aggregate: json["aggregate"], id: json["id"],
139
+ payload: JSON.parse(JSON.generate(json["payload"]), symbolize_names: true), occurred_at: nil)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,96 @@
1
+ require_relative "errors"
2
+ require_relative "refusal_wording"
3
+
4
+ module Hecks
5
+ module Runtime
6
+ # The invocation address is not part of a command's domain payload.
7
+ # Aggregate commands carry one receiver identity; entity commands carry
8
+ # the aggregate receiver followed by one identity for every entity hop.
9
+ module Routing
10
+ Envelope = Struct.new(:aggregate, :entities, keyword_init: true) do
11
+ def initialize(aggregate:, entities: [])
12
+ super(aggregate: aggregate.to_s, entities: Array(entities).map(&:to_s).freeze)
13
+ freeze
14
+ end
15
+ end
16
+
17
+ module_function
18
+
19
+ def envelope(to, entity_depth: 0)
20
+ return nil if to.nil?
21
+
22
+ aggregate, entities = if to.is_a?(Hash)
23
+ hash = to.transform_keys(&:to_sym)
24
+ unknown = hash.keys - %i[aggregate entity entities]
25
+ unless unknown.empty?
26
+ raise TypeMismatch, "to: does not recognize #{unknown.sort.join(', ')}"
27
+ end
28
+
29
+ [hash[:aggregate], entity_identities(hash)]
30
+ else
31
+ [to, []]
32
+ end
33
+
34
+ if aggregate.nil? || aggregate.to_s.empty?
35
+ raise TypeMismatch, "to: must name the receiving aggregate identity"
36
+ end
37
+ if entities.size != entity_depth
38
+ raise TypeMismatch,
39
+ "to: for an entity command needs #{entity_depth} entity " \
40
+ "#{entity_depth == 1 ? 'identity' : 'identities'} after the aggregate — got #{entities.size}"
41
+ end
42
+ if entities.any? { |identity| identity.nil? || identity.to_s.empty? }
43
+ raise TypeMismatch, "to: contains a blank entity identity"
44
+ end
45
+
46
+ Envelope.new(aggregate: aggregate, entities: entities)
47
+ end
48
+
49
+ # `with:` is deliberately strict. Compatibility-only calls still pass
50
+ # loose keyword arguments through the old addressing gate, but a caller
51
+ # choosing the explicit envelope cannot smuggle receiver identity back
52
+ # into the payload.
53
+ def payload(command, with:, legacy:)
54
+ if with && !legacy.empty?
55
+ raise TypeMismatch, "dispatch takes command facts in with:, not both with: and loose keyword arguments"
56
+ end
57
+
58
+ return legacy unless with
59
+ unless with.is_a?(Hash)
60
+ raise TypeMismatch, "with: must be a hash of command facts"
61
+ end
62
+
63
+ offered = with.transform_keys(&:to_sym)
64
+ declared = command.attributes.map { |attribute| attribute.name.to_sym }
65
+ unknown = (offered.keys - declared).sort
66
+ unless unknown.empty?
67
+ reading = declared.empty? ? "none" : declared.join(", ")
68
+ raise UnknownArgument,
69
+ RefusalWording.render("UnknownArgument", "unknown_args",
70
+ command: command.hecks_name, unknown: unknown.join(", "),
71
+ declared: reading)
72
+ end
73
+
74
+ absent = command.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } - offered.keys
75
+ unless absent.empty?
76
+ reading = declared.empty? ? "none" : declared.join(", ")
77
+ raise AbsentArgument,
78
+ RefusalWording.render("AbsentArgument", "absent_args",
79
+ command: command.hecks_name, absent: absent.sort.join(", "),
80
+ declared: reading)
81
+ end
82
+
83
+ offered
84
+ end
85
+
86
+ def entity_identities(hash)
87
+ if hash.key?(:entities) && hash.key?(:entity)
88
+ raise TypeMismatch, "to: takes entity: or entities:, not both"
89
+ end
90
+
91
+ hash.key?(:entities) ? Array(hash[:entities]) : Array(hash[:entity])
92
+ end
93
+ private_class_method :entity_identities
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,97 @@
1
+ require_relative "../value"
2
+
3
+ module Hecks
4
+ module Runtime
5
+ class SagaInterpreter
6
+ # How a saga decides WHICH CONVERSATION an event belongs to — three
7
+ # tiers, each one a lesson.
8
+ module Correlation
9
+ private
10
+
11
+ # A DOTTED PATH NAMES THE SCALAR FIELD, rather than asking a value object
12
+ # to stand in for one. `correlates_by :end_to_end` would key a saga on
13
+ # the whole ExternalTransfer::EndToEndReference — and what a non-scalar
14
+ # correlation key even IS is representation-dependent (the object
15
+ # itself? its serialised text?). `:"end_to_end.value"` reads the one
16
+ # field with a single unambiguous rendering.
17
+ def saga_correlation(pm, event)
18
+ path = pm.correlates_by.to_s.split(".")
19
+ # A LATER EVENT MAY ALREADY HOLD THE SCALAR. `reference.value` digs a
20
+ # value object's field out of a FRESH declaration (TransferRequested's
21
+ # `reference` IS a TransferReference) — but a downstream event this
22
+ # same value was smuggled through as a passthrough argument
23
+ # (AccountDebited's `reference:`, resolved by `dispatch_args` to the
24
+ # bare correlation string) carries it as a scalar already, with
25
+ # nothing left to dig. `"xfer-1".respond_to?(:[])` is true — String
26
+ # has its OWN `[]` (substring indexing) — so checking for keyed
27
+ # lookup explicitly, rather than "responds to `[]` at all", is what
28
+ # stops the second segment from being read as a symbol index into a
29
+ # string that has already arrived.
30
+ value = path.reduce(event.payload) do |held, segment|
31
+ held.is_a?(Hash) || held.is_a?(Value) ? held[segment.to_sym] : held
32
+ end
33
+ return value unless value.to_s.empty?
34
+
35
+ # THE STAMP — `deliver_saga_dispatch` marks its own event before this
36
+ # saga's next step ever asks, for a leg whose command declares
37
+ # NEITHER the correlation field itself nor the emitting aggregate's
38
+ # own reference key (the two tiers above). command_interpreter/
39
+ # argument_gate.rb names the old payload-only lookup "the weakest
40
+ # part of the gate" : a correlation key arriving on a command only
41
+ # because `correlation_keys` widens the undeclared-argument
42
+ # allow-list domain-wide. This is the additive fix — a leg that
43
+ # carries nothing correlation-shaped at all still correlates,
44
+ # because the saga that dispatched it already knows the answer.
45
+ # Keyed by `correlation_head`
46
+ # rather than a bare scalar so an event stamped by one saga cannot be
47
+ # misread by an unrelated one correlating on a different field.
48
+ stamped = event.correlation && event.correlation[pm.correlation_head.to_s]
49
+ return stamped unless stamped.nil? || stamped.to_s.empty?
50
+
51
+ # A SELF-REFERENCING LEG carries the correlation forward under its
52
+ # own emitting record's identity — `event.id`, not a field dug back
53
+ # out of the payload. This used to read `event.payload[own_key]`
54
+ # (`own_key` the aggregate's own reference-key convention, "wire",
55
+ # "transfer"), which only ever held a value because LEGACY dispatch
56
+ # left the self-addressing key riding along in the payload
57
+ # unfiltered. Routing separated from payload (`to:`/`with:`, the
58
+ # facade's own `Handle#run` always uses it) closed exactly that
59
+ # leak — correctly, since an addressing key is not a fact the
60
+ # payload should carry — which left this tier reading an empty
61
+ # Hash for any self-referencing leg with no OTHER declared
62
+ # attributes (`OnboardingCase.Clear`, `.Decline` — no `attribute`
63
+ # lines at all): the saga silently stopped advancing, forever, for
64
+ # exactly the leg this tier exists to correlate.
65
+ #
66
+ # `event.id` says the identical thing this tier always meant —
67
+ # "the record that just emitted this event, by its own identity" —
68
+ # and unlike a payload dig it is populated by the record itself,
69
+ # not by which dispatch convention the caller happened to use.
70
+ #
71
+ # GATED, still — a manually-dispatched command on a wholly
72
+ # UNRELATED aggregate can share an event NAME this pm happens to
73
+ # handle (`Drawer.Take` also emits "Taken", the same name a
74
+ # SAGA-DISPATCHED leg uses) with nothing this saga should read as
75
+ # its own conversation. What makes a leg genuinely
76
+ # self-referencing — the one fact worth trusting `event.id`
77
+ # for — is that `correlates_by`'s own head field IS this event's
78
+ # OWN aggregate's declared identity, not merely a same-shaped
79
+ # name: `OnboardingCase.identity_heads` really does include
80
+ # `:reference`, `correlates_by :"reference.value"`'s own head ;
81
+ # `Drawer.identity_heads` is `[:number]`, nowhere close.
82
+ self_identified?(pm, event) ? event.id : nil
83
+ end
84
+
85
+ def self_identified?(pm, event)
86
+ domain, bare_name = event.aggregate.to_s.split("::", 2)
87
+ return false unless bare_name
88
+
89
+ construct = @registry.bluebook(domain)&.aggregate(bare_name)
90
+ return false unless construct
91
+
92
+ construct.identity_heads.map(&:to_s).include?(pm.correlation_head.to_s)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,301 @@
1
+ require "json"
2
+ require_relative "saga_interpreter/correlation"
3
+ require_relative "../bluebook/process_manager"
4
+ require_relative "errors"
5
+ require_relative "reaction_invocation"
6
+ require_relative "value"
7
+
8
+ module Hecks
9
+ module Runtime
10
+ class SagaInterpreter
11
+ include Correlation
12
+
13
+ # The trigger lives on the declaration it triggers, not on the runtime that
14
+ # notices it — see ProcessManager::REFUSED.
15
+ REFUSED = Bluebook::ProcessManager::REFUSED
16
+
17
+ # A crash gets this many extra attempts before the procedure gives up
18
+ # and treats it as something to compensate for — see
19
+ # `deliver_saga_dispatch`'s own comment for why a crash isn't unwound
20
+ # on the first failure the way a domain refusal is.
21
+ MAX_DEFECT_RETRIES = 3
22
+
23
+ attr_reader :registry
24
+
25
+ def initialize(registry, door:)
26
+ @registry = registry
27
+ @door = door
28
+ end
29
+
30
+ def advance(event, domain)
31
+ bluebook = @registry.bluebook(domain)
32
+ return unless bluebook
33
+
34
+ bluebook.process_managers.each do |pm|
35
+ begin_saga(pm, event, domain)
36
+ advance_saga(pm, event, domain)
37
+ end_saga(pm, event, domain)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # THE CHECKPOINT WRITE, shared by every mutation site below —
44
+ # holds `saga_mutex` across BOTH the in-memory Hash mutation and
45
+ # the persistence write (§7), not just the Hash mutation alone:
46
+ # two threads racing the SAME (process_manager, correlation) key
47
+ # could otherwise interleave their writes out of order, silently
48
+ # reordering a saga's own transition history — worse for the
49
+ # adapters with no locking of their own (Heki) than for Postgres.
50
+ # `deep_copy` guards against the exact shape of bug PR #175 itself
51
+ # already found once (over-freezing a live, still-mutated Hash) —
52
+ # never hand a persistence adapter the SAME object `advance_saga`/
53
+ # `unwind` go on to mutate in place; round-tripping through JSON
54
+ # is also what guarantees the value is safe for every adapter that
55
+ # itself calls `JSON.generate` on it.
56
+ def checkpoint(pm, correlation, instance, domain)
57
+ @registry.saga_persistence(domain).save_saga(
58
+ process_manager: pm.name, correlation: correlation,
59
+ state: instance[:state], memory: deep_copy(instance[:memory])
60
+ )
61
+ end
62
+
63
+ def deep_copy(hash) = JSON.parse(JSON.generate(hash), symbolize_names: true)
64
+
65
+ def begin_saga(pm, event, domain)
66
+ return unless event.name == pm.starts_on
67
+
68
+ correlation = saga_correlation(pm, event)
69
+ if correlation.to_s.empty?
70
+ @registry.saga_log << { process_manager: pm.name, on: event.name,
71
+ born: false, reason: "no #{pm.correlates_by} in the payload" }
72
+ return
73
+ end
74
+
75
+ created = @registry.saga_mutex.synchronize do
76
+ next false if @registry.saga_instances[pm.name].key?(correlation)
77
+
78
+ instance = { state: pm.states.first, memory: event.payload }
79
+ @registry.saga_instances[pm.name][correlation] = instance
80
+ checkpoint(pm, correlation, instance, domain)
81
+ true
82
+ end
83
+ return unless created
84
+
85
+ @registry.saga_log << { process_manager: pm.name, on: event.name,
86
+ instance: correlation, born: true, state: pm.states.first }
87
+ end
88
+
89
+ # THE MUTEX COVERS ONLY THE STATE-CHECK-AND-MUTATE-AND-CHECKPOINT
90
+ # STEP, never the dispatch cascade that follows — `deliver_saga_
91
+ # dispatch` calls `@door.reenter`, which can recursively re-enter
92
+ # THIS SAME interpreter (a saga's own leg triggering another saga,
93
+ # or itself again) on the SAME thread, and `Mutex` is not
94
+ # reentrant: holding it across that call would deadlock the
95
+ # thread against itself the moment any real chain did that.
96
+ def advance_saga(pm, event, domain)
97
+ handler = pm.handler_for(event.name)
98
+ return unless handler
99
+
100
+ correlation = saga_correlation(pm, event)
101
+ return if correlation.to_s.empty?
102
+
103
+ record = { process_manager: pm.name, on: event.name, instance: correlation }
104
+ instance = nil
105
+
106
+ advanced = @registry.saga_mutex.synchronize do
107
+ instance = @registry.saga_instances[pm.name][correlation]
108
+ unless instance
109
+ @registry.saga_log << record.merge(advanced: false, reason: "no conversation remembers #{correlation.inspect}")
110
+ next false
111
+ end
112
+ unless instance[:state] == handler.from_state
113
+ @registry.saga_log << record.merge(advanced: false,
114
+ reason: "in #{instance[:state].inspect}, not #{handler.from_state.inspect}")
115
+ next false
116
+ end
117
+
118
+ instance[:state] = handler.to_state
119
+ checkpoint(pm, correlation, instance, domain)
120
+ true
121
+ end
122
+ return unless advanced
123
+
124
+ @registry.saga_log << record.merge(advanced: true, from: handler.from_state, to: handler.to_state)
125
+
126
+ handler.dispatches.each do |spec|
127
+ deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
128
+ end
129
+ end
130
+
131
+ def deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
132
+ args = dispatch_args(pm, spec, event, instance, correlation)
133
+ record = { process_manager: pm.name, instance: correlation, dispatch: spec.command_name }
134
+
135
+ # THE RAW INPUTS `args` WAS RESOLVED FROM, captured alongside the
136
+ # result — never re-derived from history[:saga_instances] later
137
+ # (that only ever holds the FINAL memory, after every step has
138
+ # run; this dispatch's own memory, at the moment it actually
139
+ # fired, is a different fact for a saga whose memory keeps
140
+ # changing). `spec.with_spec.empty?` skipped: nothing declared
141
+ # to check, a tautological pass, the same reason a command with
142
+ # no givens/from is skipped by lifecycle_guard_and_given_
143
+ # violations_are_refused.
144
+ unless spec.with_spec.to_a.empty?
145
+ @registry.saga_dispatch_log << { process_manager: pm.name, instance: correlation, dispatch: spec.command_name,
146
+ on: event.name, correlation_head: pm.correlation_head,
147
+ event_payload: event.payload, memory: Value.materialize(instance[:memory]),
148
+ with_spec: spec.with_spec, args: args }
149
+ end
150
+
151
+ if @door.reaction_depth_reached?
152
+ # THE CEILING IS NOT A DOMAIN DECISION EITHER — same reasoning as a
153
+ # crash, below — but unlike a crash there is nothing ambiguous
154
+ # about it: the leg unambiguously did not run, so it unwinds
155
+ # exactly like a refusal instead of stranding the instance for a
156
+ # human to notice. `unwind`'s own state guard (it moves to its
157
+ # `to_state` before its dispatches run) is what keeps this from
158
+ # looping if the ceiling is still in effect when the compensating
159
+ # leg tries to dispatch — that leg's own attempt hits this same
160
+ # branch, calls `unwind` again, and finds the instance already
161
+ # past `from_state`.
162
+ @registry.saga_log << record.merge(delivered: false,
163
+ reason: "reaction depth #{@door.max_reaction_depth} reached")
164
+ unwind(pm, event, instance, correlation, domain)
165
+ return
166
+ end
167
+
168
+ attempt = 0
169
+ begin
170
+ invocation = ReactionInvocation.build(
171
+ registry: @registry,
172
+ verb: qualified(spec.command_name, domain),
173
+ projected: args,
174
+ explicit: ReactionInvocation.projection_declared?(spec),
175
+ passthrough: [pm.correlation_head],
176
+ source_receiver: { aggregate: event.aggregate, identity: event.id }
177
+ )
178
+ @door.reenter(qualified(spec.command_name, domain),
179
+ saga_correlation: { pm.correlation_head.to_s => correlation }, **invocation)
180
+ @registry.saga_log << record.merge(delivered: true)
181
+ rescue *DOMAIN_REFUSALS => error
182
+ # Same rule as the policy interpreter : a refusal by the target is
183
+ # a recorded outcome, and the leg that raised it UNWINDS — see
184
+ # `unwind`'s own comment for why the procedure runs its
185
+ # compensation here rather than leaving the money (or whatever
186
+ # else a leg moved) sitting out.
187
+ @registry.saga_log << record.merge(delivered: false, reason: error.message)
188
+ unwind(pm, event, instance, correlation, domain)
189
+ rescue StandardError => error
190
+ # A DEFECT, not a refusal — see PolicyInterpreter#deliver's own
191
+ # comment for the full reasoning: the same DOMAIN_REFUSALS split,
192
+ # and the same "the triggering command already succeeded and
193
+ # persisted by the time this runs" fact that makes catching it
194
+ # here safe rather than reckless.
195
+ #
196
+ # UNLIKE a refusal, a crash is not a decision the domain made, so
197
+ # it does not unwind on the first failure — MAX_DEFECT_RETRIES
198
+ # gives a transient failure (a DB timeout, a race, a cold start)
199
+ # a chance to clear on its own, retrying the identical dispatch,
200
+ # before this is treated as something to compensate for. Every
201
+ # attempt is recorded distinguishably (`defect: true`, the
202
+ # error's own class); only once retries are exhausted is it
203
+ # warned to STDERR and unwound — tagged `defect_compensated:
204
+ # true` rather than folded into an ordinary refusal's shape, so
205
+ # the log never misrepresents a crash as a decision the domain
206
+ # made. Compensating a genuinely stuck leg beats leaving it for a
207
+ # human to find; misrepresenting *why* it compensated is what the
208
+ # tag is for.
209
+ attempt += 1
210
+ if attempt <= MAX_DEFECT_RETRIES
211
+ @registry.saga_log << record.merge(delivered: false, reason: error.message,
212
+ defect: true, error_class: error.class.name,
213
+ attempt: attempt, retrying: true)
214
+ retry
215
+ end
216
+
217
+ warn "[hecks] defect in saga #{pm.name} — instance #{correlation.inspect} " \
218
+ "dispatching #{spec.command_name} after #{attempt} attempts: #{error.class}: #{error.message}"
219
+ @registry.saga_log << record.merge(delivered: false, reason: error.message, defect: true,
220
+ error_class: error.class.name, defect_compensated: true)
221
+ unwind(pm, event, instance, correlation, domain)
222
+ end
223
+ end
224
+
225
+ # A refused leg UNWINDS — the procedure runs the leg declared `on :refused`,
226
+ # which is where the compensation lives. So does a leg that hit the
227
+ # reaction-depth ceiling, and so does a leg that crashed and stayed
228
+ # crashing through MAX_DEFECT_RETRIES — see `deliver_saga_dispatch`'s own
229
+ # comments for why each of those is safe to route here.
230
+ #
231
+ # Until this existed a refusal was RECORDED and nothing else happened. The
232
+ # wire's thousand was taken from the source, refused by the destination, and
233
+ # sat nowhere until a human dispatched the reversal by hand ; banking's
234
+ # settlement left a debit standing with no credit and no reversal at all.
235
+ # Both bluebooks had written the compensating leg. Nothing armed it.
236
+ #
237
+ # A compensation that is itself refused does NOT unwind again, and needs no
238
+ # flag to stop it: the state moves to the compensating leg's to_state BEFORE
239
+ # its dispatches run, so a second refusal finds the instance no longer in
240
+ # from_state and records that instead. The check is the guard.
241
+ def unwind(pm, event, instance, correlation, domain)
242
+ handler = pm.handler_for(REFUSED)
243
+ return unless handler && instance
244
+
245
+ record = { process_manager: pm.name, on: REFUSED, instance: correlation }
246
+
247
+ # Same non-reentrancy reasoning as `advance_saga`'s own comment —
248
+ # the mutex covers only the check-and-mutate-and-checkpoint step.
249
+ advanced = @registry.saga_mutex.synchronize do
250
+ unless instance[:state] == handler.from_state
251
+ @registry.saga_log << record.merge(advanced: false,
252
+ reason: "in #{instance[:state].inspect}, not #{handler.from_state.inspect}")
253
+ next false
254
+ end
255
+
256
+ instance[:state] = handler.to_state
257
+ checkpoint(pm, correlation, instance, domain)
258
+ true
259
+ end
260
+ return unless advanced
261
+
262
+ @registry.saga_log << record.merge(advanced: true, from: handler.from_state, to: handler.to_state)
263
+
264
+ handler.dispatches.each do |spec|
265
+ deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
266
+ end
267
+ end
268
+
269
+ def dispatch_args(pm, spec, event, instance, correlation)
270
+ ReactionInvocation.resolve_mapping(
271
+ with_spec: spec.with_spec,
272
+ scopes: [["current event payload", event.payload], ["opening event memory", instance[:memory]]],
273
+ bindings: { pm.correlation_head => correlation },
274
+ label: "#{pm.name}'s dispatch #{spec.command_name}"
275
+ )
276
+ end
277
+
278
+ def qualified(command_name, domain)
279
+ command_name.include?("::") ? command_name : "#{domain}::#{command_name}"
280
+ end
281
+
282
+ def end_saga(pm, event, domain)
283
+ return unless event.name == pm.ends_on
284
+
285
+ correlation = saga_correlation(pm, event)
286
+ return if correlation.to_s.empty?
287
+
288
+ ended = @registry.saga_mutex.synchronize do
289
+ next false unless @registry.saga_instances[pm.name].delete(correlation)
290
+
291
+ @registry.saga_persistence(domain).delete_saga(process_manager: pm.name, correlation: correlation)
292
+ true
293
+ end
294
+ return unless ended
295
+
296
+ @registry.saga_log << { process_manager: pm.name, on: event.name,
297
+ instance: correlation, ended: true }
298
+ end
299
+ end
300
+ end
301
+ end