hecks 0.2.0 → 1.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 +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 +427 -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 +101 -0
  12. data/lib/hecks/adapters/driven/heki/journal.rb +56 -0
  13. data/lib/hecks/adapters/driven/heki/saga_store.rb +99 -0
  14. data/lib/hecks/adapters/driven/heki/snapshot.rb +65 -0
  15. data/lib/hecks/adapters/driven/heki.adapter +4 -0
  16. data/lib/hecks/adapters/driven/heki.rb +179 -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 +145 -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 +88 -0
  28. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +207 -0
  29. data/lib/hecks/adapters/driven/postgres.adapter +5 -0
  30. data/lib/hecks/adapters/driven/postgres.rb +438 -0
  31. data/lib/hecks/adapters/driven/postgres_era.adapter +17 -0
  32. data/lib/hecks/adapters/driven/prism.adapter +3 -0
  33. data/lib/hecks/adapters/driven/prism.rb +80 -0
  34. data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
  35. data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
  36. data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
  37. data/lib/hecks/adapters/driven/sqlite/codec.rb +80 -0
  38. data/lib/hecks/adapters/driven/sqlite/projection.rb +172 -0
  39. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +192 -0
  40. data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
  41. data/lib/hecks/adapters/driven/sqlite.rb +306 -0
  42. data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
  43. data/lib/hecks/adapters/driven/system_clock.rb +14 -0
  44. data/lib/hecks/adapters/driven.rb +54 -0
  45. data/lib/hecks/adapters.rb +6 -0
  46. data/lib/hecks/behaviors/dsl.rb +125 -0
  47. data/lib/hecks/behaviors/expectations.rb +340 -0
  48. data/lib/hecks/behaviors/ir.rb +31 -0
  49. data/lib/hecks/behaviors/rspec.rb +42 -0
  50. data/lib/hecks/behaviors/runner.rb +96 -0
  51. data/lib/hecks/behaviors.rb +25 -0
  52. data/lib/hecks/bluebook/aggregate.rb +108 -0
  53. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
  54. data/lib/hecks/bluebook/assembly/build.rb +48 -0
  55. data/lib/hecks/bluebook/assembly/contract.rb +112 -0
  56. data/lib/hecks/bluebook/assembly/contracts.rb +438 -0
  57. data/lib/hecks/bluebook/assembly/marks.rb +228 -0
  58. data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
  59. data/lib/hecks/bluebook/assembly.rb +91 -0
  60. data/lib/hecks/bluebook/attribute.rb +96 -0
  61. data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
  62. data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
  63. data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
  64. data/lib/hecks/bluebook/behaviour/command.rb +116 -0
  65. data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
  66. data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
  67. data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
  68. data/lib/hecks/bluebook/behaviour/lifecycle.rb +68 -0
  69. data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
  70. data/lib/hecks/bluebook/behaviour/process_manager.rb +51 -0
  71. data/lib/hecks/bluebook/behaviour/query.rb +10 -0
  72. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
  73. data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
  74. data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
  75. data/lib/hecks/bluebook/chapter.rb +78 -0
  76. data/lib/hecks/bluebook/command.rb +124 -0
  77. data/lib/hecks/bluebook/domain_port.rb +102 -0
  78. data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
  79. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +1018 -0
  80. data/lib/hecks/bluebook/dsl/attribute_collector.rb +348 -0
  81. data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
  82. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1087 -0
  83. data/lib/hecks/bluebook/dsl/command_builder.rb +714 -0
  84. data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
  85. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
  86. data/lib/hecks/bluebook/dsl/entity_builder.rb +430 -0
  87. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
  88. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
  89. data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
  90. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
  91. data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
  92. data/lib/hecks/bluebook/dsl/policy_builder.rb +135 -0
  93. data/lib/hecks/bluebook/dsl/port_builder.rb +39 -0
  94. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
  95. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +306 -0
  96. data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
  97. data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
  98. data/lib/hecks/bluebook/dsl/rule_reference.rb +173 -0
  99. data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
  100. data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
  101. data/lib/hecks/bluebook/dsl/word_gate.rb +228 -0
  102. data/lib/hecks/bluebook/dsl/world_builder.rb +117 -0
  103. data/lib/hecks/bluebook/dsl.rb +45 -0
  104. data/lib/hecks/bluebook/entity.rb +103 -0
  105. data/lib/hecks/bluebook/expression/canonical_form.rb +123 -0
  106. data/lib/hecks/bluebook/expression/evaluator.rb +304 -0
  107. data/lib/hecks/bluebook/expression/projection.json +218 -0
  108. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
  109. data/lib/hecks/bluebook/expression/resolver.rb +781 -0
  110. data/lib/hecks/bluebook/expression.rb +13 -0
  111. data/lib/hecks/bluebook/hexagon.rb +60 -0
  112. data/lib/hecks/bluebook/lifecycle.rb +42 -0
  113. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
  114. data/lib/hecks/bluebook/meta_validator/judge.rb +621 -0
  115. data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
  116. data/lib/hecks/bluebook/meta_validator/port_judge.rb +51 -0
  117. data/lib/hecks/bluebook/meta_validator/readings.rb +360 -0
  118. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +351 -0
  119. data/lib/hecks/bluebook/meta_validator/shapes.rb +266 -0
  120. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
  121. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
  122. data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
  123. data/lib/hecks/bluebook/meta_validator.rb +542 -0
  124. data/lib/hecks/bluebook/model_check.rb +445 -0
  125. data/lib/hecks/bluebook/pattern_subset.rb +184 -0
  126. data/lib/hecks/bluebook/policy.rb +41 -0
  127. data/lib/hecks/bluebook/process_manager.rb +128 -0
  128. data/lib/hecks/bluebook/project_discovery.rb +30 -0
  129. data/lib/hecks/bluebook/project_loader.rb +40 -0
  130. data/lib/hecks/bluebook/project_register.rb +82 -0
  131. data/lib/hecks/bluebook/query.rb +61 -0
  132. data/lib/hecks/bluebook/read_model.rb +109 -0
  133. data/lib/hecks/bluebook/reference.rb +74 -0
  134. data/lib/hecks/bluebook/smoke_test.rb +166 -0
  135. data/lib/hecks/bluebook/synthesizer.rb +95 -0
  136. data/lib/hecks/bluebook/translation.rb +92 -0
  137. data/lib/hecks/bluebook/value_object.rb +66 -0
  138. data/lib/hecks/bluebook.rb +74 -0
  139. data/lib/hecks/codemod.rb +342 -0
  140. data/lib/hecks/construct.rb +71 -0
  141. data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
  142. data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
  143. data/lib/hecks/deploy/oidc.json +18 -0
  144. data/lib/hecks/doc/reference.rb +410 -0
  145. data/lib/hecks/embryonaut_bluebook.rb +75 -0
  146. data/lib/hecks/facade/cli_door.rb +119 -0
  147. data/lib/hecks/facade/cli_runner.rb +190 -0
  148. data/lib/hecks/facade/command_request.rb +105 -0
  149. data/lib/hecks/facade/handle.rb +173 -0
  150. data/lib/hecks/facade/json_door.rb +166 -0
  151. data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
  152. data/lib/hecks/facade/surface/chapter.rb +107 -0
  153. data/lib/hecks/facade/surface.rb +48 -0
  154. data/lib/hecks/facade.rb +44 -0
  155. data/lib/hecks/forms/app.rb +341 -0
  156. data/lib/hecks/forms/command_form_renderer.rb +113 -0
  157. data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
  158. data/lib/hecks/forms/field_renderer.rb +177 -0
  159. data/lib/hecks/forms/field_shape.rb +232 -0
  160. data/lib/hecks/forms/html.rb +84 -0
  161. data/lib/hecks/forms/index_renderer.rb +35 -0
  162. data/lib/hecks/forms/page.rb +157 -0
  163. data/lib/hecks/forms/params.rb +161 -0
  164. data/lib/hecks/forms/port_argument.rb +46 -0
  165. data/lib/hecks/forms/query_form_renderer.rb +114 -0
  166. data/lib/hecks/forms/record_renderer.rb +119 -0
  167. data/lib/hecks/forms/record_table.rb +68 -0
  168. data/lib/hecks/forms/reference_options.rb +30 -0
  169. data/lib/hecks/forms/value_object_shape.rb +46 -0
  170. data/lib/hecks/forms.rb +54 -0
  171. data/lib/hecks/fqn.rb +94 -0
  172. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
  173. data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
  174. data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
  175. data/lib/hecks/framework/bluebook/governance.bluebook +145 -0
  176. data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
  177. data/lib/hecks/framework/oidc.json +39 -0
  178. data/lib/hecks/framework.rb +90 -0
  179. data/lib/hecks/freezer.rb +67 -0
  180. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  181. data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
  182. data/lib/hecks/fuzzing/isolated_boot.rb +286 -0
  183. data/lib/hecks/fuzzing/properties.rb +1192 -0
  184. data/lib/hecks/fuzzing/replay.rb +668 -0
  185. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
  186. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +80 -0
  187. data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
  188. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +250 -0
  189. data/lib/hecks/fuzzing/sequence_generator.rb +131 -0
  190. data/lib/hecks/fuzzing/value_generator.rb +200 -0
  191. data/lib/hecks/fuzzing.rb +12 -0
  192. data/lib/hecks/grammar/evolve.rb +324 -0
  193. data/lib/hecks/grammar/expression.bluebook +420 -0
  194. data/lib/hecks/grammar/expression_operators.json +1648 -0
  195. data/lib/hecks/grammar/grammar.hecksagon +20 -0
  196. data/lib/hecks/grammar/oidc.json +69 -0
  197. data/lib/hecks/grammar/translation.bluebook +203 -0
  198. data/lib/hecks/grammar.rb +178 -0
  199. data/lib/hecks/ir.rb +126 -0
  200. data/lib/hecks/language/adapter.bluebook +116 -0
  201. data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
  202. data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
  203. data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
  204. data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
  205. data/lib/hecks/language/bluebook/command.bluebook +471 -0
  206. data/lib/hecks/language/bluebook/entity.bluebook +392 -0
  207. data/lib/hecks/language/bluebook/policy.bluebook +189 -0
  208. data/lib/hecks/language/bluebook/process_manager.bluebook +381 -0
  209. data/lib/hecks/language/bluebook/projection.bluebook +267 -0
  210. data/lib/hecks/language/bluebook/query.bluebook +245 -0
  211. data/lib/hecks/language/bluebook/shape.bluebook +292 -0
  212. data/lib/hecks/language/bluebook/syntax.bluebook +448 -0
  213. data/lib/hecks/language/bluebook/vocabulary.bluebook +379 -0
  214. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
  215. data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
  216. data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
  217. data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
  218. data/lib/hecks/language/oidc.json +333 -0
  219. data/lib/hecks/language/port.bluebook +120 -0
  220. data/lib/hecks/language/translation/translation.bluebook +110 -0
  221. data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
  222. data/lib/hecks/language/world/wiring.bluebook +62 -0
  223. data/lib/hecks/language/world/world.bluebook +84 -0
  224. data/lib/hecks/literal.rb +125 -0
  225. data/lib/hecks/naming.rb +174 -0
  226. data/lib/hecks/ports/access_control.port +9 -0
  227. data/lib/hecks/ports/access_control.rb +62 -0
  228. data/lib/hecks/ports/agent/answers.rb +104 -0
  229. data/lib/hecks/ports/agent.port +8 -0
  230. data/lib/hecks/ports/agent.rb +167 -0
  231. data/lib/hecks/ports/authentication.port +6 -0
  232. data/lib/hecks/ports/authentication.rb +50 -0
  233. data/lib/hecks/ports/authorization.port +7 -0
  234. data/lib/hecks/ports/authorization.rb +62 -0
  235. data/lib/hecks/ports/clock.port +5 -0
  236. data/lib/hecks/ports/clock.rb +62 -0
  237. data/lib/hecks/ports/extraction.port +5 -0
  238. data/lib/hecks/ports/extraction.rb +37 -0
  239. data/lib/hecks/ports/identity_assignment.port +5 -0
  240. data/lib/hecks/ports/identity_assignment.rb +45 -0
  241. data/lib/hecks/ports/identity_generation.port +5 -0
  242. data/lib/hecks/ports/identity_generation.rb +49 -0
  243. data/lib/hecks/ports/identity_resolution.port +5 -0
  244. data/lib/hecks/ports/identity_resolution.rb +40 -0
  245. data/lib/hecks/ports/loading.port +4 -0
  246. data/lib/hecks/ports/loading.rb +13 -0
  247. data/lib/hecks/ports/persistence/append_only.rb +138 -0
  248. data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
  249. data/lib/hecks/ports/persistence/execution.rb +23 -0
  250. data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
  251. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  252. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +169 -0
  253. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +128 -0
  254. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +161 -0
  255. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +61 -0
  256. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +304 -0
  257. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +171 -0
  258. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +190 -0
  259. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +478 -0
  260. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +166 -0
  261. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +314 -0
  262. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +168 -0
  263. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +170 -0
  264. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +134 -0
  265. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +137 -0
  266. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +89 -0
  267. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +85 -0
  268. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
  269. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +132 -0
  270. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +74 -0
  271. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +758 -0
  272. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +120 -0
  273. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +31 -0
  274. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +46 -0
  275. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +102 -0
  276. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +46 -0
  277. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +70 -0
  278. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +72 -0
  279. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +120 -0
  280. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +183 -0
  281. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +41 -0
  282. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +28 -0
  283. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +30 -0
  284. data/lib/hecks/ports/persistence/plugins/era/translation.rb +11 -0
  285. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  286. data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
  287. data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
  288. data/lib/hecks/ports/persistence.port +4 -0
  289. data/lib/hecks/ports/persistence.rb +39 -0
  290. data/lib/hecks/ports/projection.port +4 -0
  291. data/lib/hecks/ports/projection.rb +96 -0
  292. data/lib/hecks/ports/query/in_memory.rb +60 -0
  293. data/lib/hecks/ports/query/ordering.rb +41 -0
  294. data/lib/hecks/ports/query.rb +36 -0
  295. data/lib/hecks/ports.rb +27 -0
  296. data/lib/hecks/projections/diagrams.rb +642 -0
  297. data/lib/hecks/projections/ir.rb +18 -0
  298. data/lib/hecks/projections/model/deviations.rb +98 -0
  299. data/lib/hecks/projections/model.rb +145 -0
  300. data/lib/hecks/projections/oidc.rb +110 -0
  301. data/lib/hecks/projections/parser_table.rb +159 -0
  302. data/lib/hecks/projections/reference.rb +38 -0
  303. data/lib/hecks/projections/shape.rb +44 -0
  304. data/lib/hecks/projections/statements.rb +110 -0
  305. data/lib/hecks/projections/vocabulary.rb +100 -0
  306. data/lib/hecks/projections.rb +32 -0
  307. data/lib/hecks/projector/cli_projector.rb +446 -0
  308. data/lib/hecks/projector/docs_projector.rb +321 -0
  309. data/lib/hecks/projector/exporter.rb +158 -0
  310. data/lib/hecks/projector/ir_projector.rb +18 -0
  311. data/lib/hecks/projector/narrate_projector.rb +243 -0
  312. data/lib/hecks/projector/target.rb +97 -0
  313. data/lib/hecks/projector.rb +186 -0
  314. data/lib/hecks/query_ir.rb +411 -0
  315. data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
  316. data/lib/hecks/query_specification/common/comparators.rb +25 -0
  317. data/lib/hecks/query_specification/common/comparison.rb +174 -0
  318. data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
  319. data/lib/hecks/query_specification/common/dsl.rb +58 -0
  320. data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
  321. data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
  322. data/lib/hecks/query_specification/common/null_policy.rb +92 -0
  323. data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
  324. data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
  325. data/lib/hecks/query_specification/common/options.rb +32 -0
  326. data/lib/hecks/query_specification/common/order_by.rb +9 -0
  327. data/lib/hecks/query_specification/common/specification.rb +10 -0
  328. data/lib/hecks/query_specification/common/where_clause.rb +9 -0
  329. data/lib/hecks/query_specification/field_path.rb +107 -0
  330. data/lib/hecks/query_specification/hop_path.rb +132 -0
  331. data/lib/hecks/query_specification/read_model/specification.rb +18 -0
  332. data/lib/hecks/query_specification.rb +14 -0
  333. data/lib/hecks/rendering.rb +48 -0
  334. data/lib/hecks/router/namespace_installer.rb +157 -0
  335. data/lib/hecks/router.rb +70 -0
  336. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  337. data/lib/hecks/runtime/boot_gates.rb +41 -0
  338. data/lib/hecks/runtime/caller.rb +66 -0
  339. data/lib/hecks/runtime/capability_graph.rb +44 -0
  340. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +135 -0
  341. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +260 -0
  342. data/lib/hecks/runtime/command_interpreter.rb +497 -0
  343. data/lib/hecks/runtime/command_rules/admissibility.rb +357 -0
  344. data/lib/hecks/runtime/command_rules/arithmetic.rb +260 -0
  345. data/lib/hecks/runtime/command_rules/authorization.rb +65 -0
  346. data/lib/hecks/runtime/command_rules/emission.rb +34 -0
  347. data/lib/hecks/runtime/command_rules/references.rb +189 -0
  348. data/lib/hecks/runtime/command_rules.rb +28 -0
  349. data/lib/hecks/runtime/dependency_planning.rb +245 -0
  350. data/lib/hecks/runtime/dispatcher.rb +301 -0
  351. data/lib/hecks/runtime/entity_element.rb +253 -0
  352. data/lib/hecks/runtime/entity_interpreter.rb +299 -0
  353. data/lib/hecks/runtime/errors.rb +122 -0
  354. data/lib/hecks/runtime/event.rb +51 -0
  355. data/lib/hecks/runtime/identity.rb +156 -0
  356. data/lib/hecks/runtime/instance.rb +175 -0
  357. data/lib/hecks/runtime/interpreting.rb +90 -0
  358. data/lib/hecks/runtime/loader.rb +184 -0
  359. data/lib/hecks/runtime/policy_interpreter.rb +366 -0
  360. data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
  361. data/lib/hecks/runtime/query_interpreter.rb +286 -0
  362. data/lib/hecks/runtime/reaction_invocation.rb +253 -0
  363. data/lib/hecks/runtime/read_model_interpreter.rb +341 -0
  364. data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
  365. data/lib/hecks/runtime/reference_hop.rb +99 -0
  366. data/lib/hecks/runtime/refusal_wording.rb +126 -0
  367. data/lib/hecks/runtime/registry/saga_persistence.rb +142 -0
  368. data/lib/hecks/runtime/registry/verification.rb +246 -0
  369. data/lib/hecks/runtime/registry.rb +279 -0
  370. data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
  371. data/lib/hecks/runtime/routing.rb +96 -0
  372. data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
  373. data/lib/hecks/runtime/saga_interpreter.rb +503 -0
  374. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  375. data/lib/hecks/runtime/tenant_check.rb +84 -0
  376. data/lib/hecks/runtime/tenant_scope.rb +55 -0
  377. data/lib/hecks/runtime/value/admission.rb +130 -0
  378. data/lib/hecks/runtime/value/coercion.rb +565 -0
  379. data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
  380. data/lib/hecks/runtime/value.rb +125 -0
  381. data/lib/hecks/runtime.rb +107 -0
  382. data/lib/hecks/storehouse.rb +632 -0
  383. data/lib/hecks/version.rb +16 -0
  384. data/lib/hecks/vocabulary.rb +218 -0
  385. data/lib/hecks.rb +123 -6
  386. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  387. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  388. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  389. metadata +412 -222
  390. data/bin/hecks +0 -7
  391. data/bin/hecks-package +0 -65
  392. data/bin/hecks_console +0 -12
  393. data/bin/hecks_serverless +0 -6
  394. data/lib/cli/build.rb +0 -14
  395. data/lib/cli/command_runner.rb +0 -28
  396. data/lib/cli/console.rb +0 -10
  397. data/lib/cli/generate.rb +0 -37
  398. data/lib/cli/hecks-cli.rb +0 -27
  399. data/lib/cli/test.rb +0 -57
  400. data/lib/console/commands.rb +0 -8
  401. data/lib/console/hecks-console.rb +0 -1
  402. data/lib/packager/README.md +0 -0
  403. data/lib/packager/app_runner.rb +0 -21
  404. data/lib/packager/args.rb +0 -26
  405. data/lib/packager/compatibility/fixnum.rb +0 -6
  406. data/lib/packager/hecks.rb +0 -39
  407. data/lib/packager/query_runner.rb +0 -21
  408. data/lib/packager/resources/Dockerfile +0 -11
  409. data/lib/packager/resources/app_binary +0 -7
  410. data/lib/packager/resources/bundle_config +0 -3
  411. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
  412. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
  413. data/lib/serverless/Domain +0 -32
  414. data/lib/serverless/cli.rb +0 -75
  415. data/lib/serverless/resources/command_name.js +0 -5
  416. data/lib/serverless/resources/environment.js +0 -7
  417. data/lib/serverless/resources/handler.js.tt +0 -28
  418. data/lib/serverless/resources/run_binary.js +0 -22
  419. data/lib/serverless/resources/serverless.yml +0 -20
@@ -0,0 +1,503 @@
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
+ require_relative "saga_pending_dispatch"
8
+
9
+ module Hecks
10
+ module Runtime
11
+ class SagaInterpreter
12
+ include Correlation
13
+
14
+ # The trigger lives on the declaration it triggers, not on the runtime that
15
+ # notices it — see ProcessManager::REFUSED.
16
+ REFUSED = Bluebook::ProcessManager::REFUSED
17
+
18
+ # A crash gets this many extra attempts before the procedure gives up
19
+ # and treats it as something to compensate for — see
20
+ # `deliver_saga_dispatch`'s own comment for why a crash isn't unwound
21
+ # on the first failure the way a domain refusal is.
22
+ MAX_DEFECT_RETRIES = 3
23
+
24
+ attr_reader :registry
25
+
26
+ def initialize(registry, door:)
27
+ @registry = registry
28
+ @door = door
29
+ end
30
+
31
+ def advance(event, domain)
32
+ bluebook = @registry.bluebook(domain)
33
+ return unless bluebook
34
+
35
+ bluebook.process_managers.each do |pm|
36
+ begin_saga(pm, event, domain)
37
+ advance_saga(pm, event, domain)
38
+ end_saga(pm, event, domain)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # THE CHECKPOINT WRITE, shared by every mutation site below —
45
+ # holds `saga_mutex` across BOTH the in-memory Hash mutation and
46
+ # the persistence write (§7), not just the Hash mutation alone:
47
+ # two threads racing the SAME (process_manager, correlation) key
48
+ # could otherwise interleave their writes out of order, silently
49
+ # reordering a saga's own transition history — worse for the
50
+ # adapters with no locking of their own (Heki) than for Postgres.
51
+ # `deep_copy` guards against the exact shape of bug PR #175 itself
52
+ # already found once (over-freezing a live, still-mutated Hash) —
53
+ # never hand a persistence adapter the SAME object `advance_saga`/
54
+ # `unwind` go on to mutate in place; round-tripping through JSON
55
+ # is also what guarantees the value is safe for every adapter that
56
+ # itself calls `JSON.generate` on it.
57
+ # `pending:` — see saga_pending_dispatch.rb. Injected into the
58
+ # WRITTEN copy of memory only, never into `instance[:memory]`
59
+ # itself: every other reader of a live instance's memory
60
+ # (`dispatch_args`'s "opening event memory" scope, the fuzzer's
61
+ # own round-trip/shape checks, `saga_spec.rb`'s exact-equality
62
+ # assertion against a fresh instance's seeded memory) sees exactly
63
+ # what it always did. The marker exists ONLY in the persisted
64
+ # blob, and only for as long as a dispatch cascade is genuinely
65
+ # in flight for this instance.
66
+ def checkpoint(pm, correlation, instance, domain, pending: nil)
67
+ memory = deep_copy(instance[:memory])
68
+ memory[SAGA_PENDING_DISPATCH_KEY] = pending if pending
69
+ @registry.saga_persistence(domain).save_saga(
70
+ process_manager: pm.name, correlation: correlation,
71
+ state: instance[:state], memory: memory,
72
+ completed_compensations: deep_copy_array(instance[:completed_compensations])
73
+ )
74
+ end
75
+
76
+ # `deep_copy` is `JSON.parse(JSON.generate(hash), ...)`, which
77
+ # only accepts an OBJECT at the top level — `completed_compensations`
78
+ # is an ARRAY, so it gets its own wrap-and-unwrap rather than a
79
+ # second, parallel `deep_copy_array` reimplementing the same
80
+ # round-trip. `|| []` — an instance from before this field existed
81
+ # (or one that has never completed a compensable leg) rehydrates
82
+ # to an empty ledger, never nil.
83
+ def deep_copy_array(array) = deep_copy(list: array || [])[:list]
84
+
85
+ def deep_copy(hash) = JSON.parse(JSON.generate(hash), symbolize_names: true)
86
+
87
+ def begin_saga(pm, event, domain)
88
+ return unless event.name == pm.starts_on
89
+
90
+ correlation = saga_correlation(pm, event)
91
+ if correlation.to_s.empty?
92
+ @registry.saga_log << { process_manager: pm.name, on: event.name,
93
+ born: false, reason: "no #{pm.correlates_by} in the payload" }
94
+ return
95
+ end
96
+
97
+ created = @registry.saga_mutex.synchronize do
98
+ next false if @registry.saga_instances[pm.name].key?(correlation)
99
+
100
+ # `.dup`, NOT THE SAME OBJECT — a fresh saga's own memory starts
101
+ # as a COPY of the starting event's own payload, never the
102
+ # payload itself. A saga's own memory is meant to be written
103
+ # into over its lifetime (remember-style, growing beyond what
104
+ # the starting event carried) ; the payload it was seeded from
105
+ # is a fact about something that ALREADY happened, logged and
106
+ # emitted before the saga ever saw it. Sharing the one Hash
107
+ # object between them means a write into the saga's own memory
108
+ # is silently ALSO a write into an already-emitted event's own
109
+ # payload — retroactively adding a field nothing announced.
110
+ # `event.payload` is deep-frozen by `Event#emit!` by the time
111
+ # this runs, so a naive in-place write here would raise
112
+ # FrozenError rather than corrupt silently — but the `.dup`
113
+ # still matters: it is what makes the saga's own memory a
114
+ # normal, writable Hash of its own, rather than one write away
115
+ # from crashing every future in-place `remember`.
116
+ instance = { state: pm.states.first, memory: event.payload.dup, completed_compensations: [] }
117
+ @registry.saga_instances[pm.name][correlation] = instance
118
+ checkpoint(pm, correlation, instance, domain)
119
+ true
120
+ end
121
+ return unless created
122
+
123
+ @registry.saga_log << { process_manager: pm.name, on: event.name,
124
+ instance: correlation, born: true, state: pm.states.first }
125
+ end
126
+
127
+ # THE MUTEX COVERS ONLY THE STATE-CHECK-AND-MUTATE-AND-CHECKPOINT
128
+ # STEP, never the dispatch cascade that follows — `deliver_saga_
129
+ # dispatch` calls `@door.reenter`, which can recursively re-enter
130
+ # THIS SAME interpreter (a saga's own leg triggering another saga,
131
+ # or itself again) on the SAME thread, and `Mutex` is not
132
+ # reentrant: holding it across that call would deadlock the
133
+ # thread against itself the moment any real chain did that.
134
+ def advance_saga(pm, event, domain)
135
+ handler = pm.handler_for(event.name)
136
+ return unless handler
137
+
138
+ correlation = saga_correlation(pm, event)
139
+ return if correlation.to_s.empty?
140
+
141
+ record = { process_manager: pm.name, on: event.name, instance: correlation }
142
+ instance = nil
143
+ pre_state = nil
144
+
145
+ advanced = @registry.saga_mutex.synchronize do
146
+ instance = @registry.saga_instances[pm.name][correlation]
147
+ unless instance
148
+ @registry.saga_log << record.merge(advanced: false, reason: "no conversation remembers #{correlation.inspect}")
149
+ next false
150
+ end
151
+ unless instance[:state] == handler.from_state
152
+ @registry.saga_log << record.merge(advanced: false,
153
+ reason: "in #{instance[:state].inspect}, not #{handler.from_state.inspect}")
154
+ next false
155
+ end
156
+
157
+ pre_state = instance[:state]
158
+ instance[:state] = handler.to_state
159
+ checkpoint(pm, correlation, instance, domain,
160
+ pending: pending_marker(event, handler, pre_state, instance[:state]))
161
+ true
162
+ end
163
+ return unless advanced
164
+
165
+ settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state)
166
+ end
167
+
168
+ def pending_marker(event, handler, from_state, to_state)
169
+ { on: event.name, from: from_state, to: to_state, dispatches: handler.dispatches.map(&:command_name) }
170
+ end
171
+
172
+ # THE SHARED TAIL of `advance_saga` and `unwind` — both are "guard,
173
+ # mutate, checkpoint-with-pending" under the mutex (kept separate
174
+ # per caller: `advance_saga`'s own guard also has to handle "no
175
+ # instance at all", `unwind`'s doesn't), then this: log the real
176
+ # observed transition, run the leg's dispatches, and clear the
177
+ # pending marker once that cascade — however it ended — is done.
178
+ def settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state, drain_compensations: false)
179
+ # `from:`/`to:` are the INSTANCE'S OWN real pre/post state — read
180
+ # back from `instance` itself, never re-derived from `handler.
181
+ # from_state`/`handler.to_state` a second time. `Properties.saga_
182
+ # advances_follow_declared_handlers` (fuzzing/properties.rb) builds
183
+ # its OWN "declared edges" list from this SAME handler object (via
184
+ # `pm.handlers`), so a log entry that just echoed `handler.
185
+ # from_state`/`handler.to_state` back could never disagree with
186
+ # that list no matter what the runtime actually did — the entry
187
+ # and the thing it's checked against would be the identical fact,
188
+ # read twice. Logging the instance's own observed state instead
189
+ # means a future defect that moves an instance somewhere its own
190
+ # declared handler didn't say (a stale handler reference, the
191
+ # wrong handler picked, a second racing mutation) shows up as a
192
+ # real mismatch instead of vanishing into a tautology.
193
+ @registry.saga_log << record.merge(advanced: true, from: pre_state, to: instance[:state])
194
+
195
+ # DERIVED COMPENSATION FIRST, NEWEST-FIRST — only for `unwind`'s
196
+ # own call (`drain_compensations: true`): every leg THIS INSTANCE
197
+ # actually completed that declared its own `compensates`, popped
198
+ # and dispatched in reverse completion order, BEFORE any
199
+ # hand-written `on :refused` dispatches below — coexistence, not
200
+ # replacement. Drained (not just read) as it fires: a saga's own
201
+ # `on :refused` handler is guarded against re-entry by `unwind`'s
202
+ # own `instance[:state] == handler.from_state` check, so this can
203
+ # only ever run once per refusal — but draining rather than
204
+ # leaving the ledger populated is what makes that true by
205
+ # construction too, not only by the state guard.
206
+ if drain_compensations
207
+ compensations = instance[:completed_compensations] || []
208
+ deliver_derived_compensation(pm, compensations.pop, correlation, domain) until compensations.empty?
209
+ checkpoint(pm, correlation, instance, domain)
210
+ end
211
+
212
+ handler.dispatches.each do |spec|
213
+ deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
214
+ end
215
+
216
+ # THE CLEAR — guarded by the SAME identity check `end_saga`'s own
217
+ # `.delete` return value implies: `deliver_saga_dispatch`'s
218
+ # `@door.reenter` can synchronously trigger this SAME correlation's
219
+ # `ends_on` event as a nested reaction (a leg's own dispatch is
220
+ # what makes the saga's terminal event fire), which deletes this
221
+ # row from the store before this line ever runs. Writing the
222
+ # clear unconditionally would RESURRECT a legitimately-ended saga
223
+ # — this diff's own first attempt did exactly that, caught by
224
+ # `saga_durability_spec.rb`'s "deletes the checkpoint once a saga
225
+ # genuinely ends" — so this only re-checkpoints when `instance`
226
+ # is still THE SAME object `@saga_instances` holds for this
227
+ # correlation (`.equal?`, not `==`: a fresh saga reborn under the
228
+ # same correlation between then and now is a DIFFERENT instance,
229
+ # and writing this stale one's state onto that one's row would be
230
+ # its own corruption). Under the mutex — dispatching is over by
231
+ # now, so this is not the reentrancy hazard `advance_saga`'s own
232
+ # comment warns about.
233
+ @registry.saga_mutex.synchronize do
234
+ next unless @registry.saga_instances[pm.name][correlation].equal?(instance)
235
+
236
+ checkpoint(pm, correlation, instance, domain, pending: nil)
237
+ end
238
+ end
239
+
240
+ def deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
241
+ args = dispatch_args(pm, spec, event, instance, correlation)
242
+ record = { process_manager: pm.name, instance: correlation, dispatch: spec.command_name }
243
+
244
+ # THE RAW INPUTS `args` WAS RESOLVED FROM, captured alongside the
245
+ # result — never re-derived from history[:saga_instances] later
246
+ # (that only ever holds the FINAL memory, after every step has
247
+ # run; this dispatch's own memory, at the moment it actually
248
+ # fired, is a different fact for a saga whose memory keeps
249
+ # changing). `spec.with_spec.empty?` skipped: nothing declared
250
+ # to check, a tautological pass, the same reason a command with
251
+ # no givens/from is skipped by lifecycle_guard_and_given_
252
+ # violations_are_refused.
253
+ unless spec.with_spec.to_a.empty?
254
+ @registry.saga_dispatch_log << { process_manager: pm.name, instance: correlation, dispatch: spec.command_name,
255
+ on: event.name, correlation_head: pm.correlation_head,
256
+ event_payload: event.payload, memory: Value.materialize(instance[:memory]),
257
+ with_spec: spec.with_spec, args: args }
258
+ end
259
+
260
+ if @door.reaction_depth_reached?
261
+ # THE CEILING IS NOT A DOMAIN DECISION EITHER — same reasoning as a
262
+ # crash, below — but unlike a crash there is nothing ambiguous
263
+ # about it: the leg unambiguously did not run, so it unwinds
264
+ # exactly like a refusal instead of stranding the instance for a
265
+ # human to notice. `unwind`'s own state guard (it moves to its
266
+ # `to_state` before its dispatches run) is what keeps this from
267
+ # looping if the ceiling is still in effect when the compensating
268
+ # leg tries to dispatch — that leg's own attempt hits this same
269
+ # branch, calls `unwind` again, and finds the instance already
270
+ # past `from_state`.
271
+ @registry.saga_log << record.merge(delivered: false,
272
+ reason: "reaction depth #{@door.max_reaction_depth} reached")
273
+ unwind(pm, event, instance, correlation, domain)
274
+ return
275
+ end
276
+
277
+ attempt = 0
278
+ compensation_recorded = false
279
+ begin
280
+ # RECORDED BEFORE DISPATCHING, not after `@door.reenter`
281
+ # returns — `@door.reenter` can recursively RE-ENTER THIS SAME
282
+ # saga interpreter (the event THIS dispatch emits triggers a
283
+ # LATER handler, which can itself refuse and unwind) entirely
284
+ # WITHIN this one call, before it ever returns here. Recording
285
+ # "after reenter succeeds" would be too late for a NESTED
286
+ # refusal to ever see this leg's own compensation — found
287
+ # live: Settlement's own AccountDebited handler refuses
288
+ # Account.Credit and unwinds from INSIDE Account.Debit's own
289
+ # `reenter` call, so "delivered: true, then record" left the
290
+ # ledger empty at the exact moment it was needed. Popped back
291
+ # off in the rescues below if THIS leg's own attempt is the
292
+ # one that failed — never left recorded for a refusal that
293
+ # was never this leg's own to compensate.
294
+ if spec.compensates && !compensation_recorded
295
+ resolved = dispatch_args(pm, spec.compensates, event, instance, correlation)
296
+ instance[:completed_compensations] << { command_name: spec.compensates.command_name, args: resolved }
297
+ checkpoint(pm, correlation, instance, domain)
298
+ compensation_recorded = true
299
+ end
300
+
301
+ invocation = ReactionInvocation.build(
302
+ registry: @registry,
303
+ verb: qualified(spec.command_name, domain),
304
+ projected: args,
305
+ explicit: ReactionInvocation.projection_declared?(spec),
306
+ passthrough: [pm.correlation_head],
307
+ source_receiver: { aggregate: event.aggregate, identity: event.id }
308
+ )
309
+ @door.reenter(qualified(spec.command_name, domain),
310
+ saga_correlation: { pm.correlation_head.to_s => correlation }, **invocation)
311
+ @registry.saga_log << record.merge(delivered: true)
312
+ rescue *DOMAIN_REFUSALS => error
313
+ unrecord_compensation(instance, correlation, domain, pm) if compensation_recorded
314
+ # Same rule as the policy interpreter : a refusal by the target is
315
+ # a recorded outcome, and the leg that raised it UNWINDS — see
316
+ # `unwind`'s own comment for why the procedure runs its
317
+ # compensation here rather than leaving the money (or whatever
318
+ # else a leg moved) sitting out.
319
+ @registry.saga_log << record.merge(delivered: false, reason: error.message)
320
+ unwind(pm, event, instance, correlation, domain)
321
+ rescue StandardError => error
322
+ unrecord_compensation(instance, correlation, domain, pm) if compensation_recorded
323
+ compensation_recorded = false
324
+ # A DEFECT, not a refusal — see PolicyInterpreter#deliver's own
325
+ # comment for the full reasoning: the same DOMAIN_REFUSALS split,
326
+ # and the same "the triggering command already succeeded and
327
+ # persisted by the time this runs" fact that makes catching it
328
+ # here safe rather than reckless.
329
+ #
330
+ # UNLIKE a refusal, a crash is not a decision the domain made, so
331
+ # it does not unwind on the first failure — MAX_DEFECT_RETRIES
332
+ # gives a transient failure (a DB timeout, a race, a cold start)
333
+ # a chance to clear on its own, retrying the identical dispatch,
334
+ # before this is treated as something to compensate for. Every
335
+ # attempt is recorded distinguishably (`defect: true`, the
336
+ # error's own class); only once retries are exhausted is it
337
+ # warned to STDERR and unwound — tagged `defect_compensated:
338
+ # true` rather than folded into an ordinary refusal's shape, so
339
+ # the log never misrepresents a crash as a decision the domain
340
+ # made. Compensating a genuinely stuck leg beats leaving it for a
341
+ # human to find; misrepresenting *why* it compensated is what the
342
+ # tag is for.
343
+ attempt += 1
344
+ if attempt <= MAX_DEFECT_RETRIES
345
+ @registry.saga_log << record.merge(delivered: false, reason: error.message,
346
+ defect: true, error_class: error.class.name,
347
+ attempt: attempt, retrying: true)
348
+ retry
349
+ end
350
+
351
+ warn "[hecks] defect in saga #{pm.name} — instance #{correlation.inspect} " \
352
+ "dispatching #{spec.command_name} after #{attempt} attempts: #{error.class}: #{error.message}"
353
+ @registry.saga_log << record.merge(delivered: false, reason: error.message, defect: true,
354
+ error_class: error.class.name, defect_compensated: true)
355
+ unwind(pm, event, instance, correlation, domain)
356
+ end
357
+ end
358
+
359
+ # THE ROLLBACK HALF of `deliver_saga_dispatch`'s own speculative
360
+ # pre-record (that method's own comment for why it has to be
361
+ # speculative) — THIS leg's own attempt is the one that failed,
362
+ # so whatever was just pushed for it was never actually earned.
363
+ # `.pop`, not a search-and-delete: nothing else can have pushed
364
+ # AFTER this leg's own entry without this leg's own `@door.
365
+ # reenter` call having already returned (the recursive re-entry
366
+ # this whole mechanism exists for only ever runs BETWEEN this
367
+ # push and this leg's own return, and a nested refusal that
368
+ # consumed it already popped it itself — this rollback only ever
369
+ # runs for THIS leg's own, still-present entry).
370
+ def unrecord_compensation(instance, correlation, domain, pm)
371
+ instance[:completed_compensations].pop
372
+ checkpoint(pm, correlation, instance, domain)
373
+ end
374
+
375
+ # A refused leg UNWINDS — the procedure runs the leg declared `on :refused`,
376
+ # which is where the compensation lives. So does a leg that hit the
377
+ # reaction-depth ceiling, and so does a leg that crashed and stayed
378
+ # crashing through MAX_DEFECT_RETRIES — see `deliver_saga_dispatch`'s own
379
+ # comments for why each of those is safe to route here.
380
+ #
381
+ # Until this existed a refusal was RECORDED and nothing else happened. The
382
+ # wire's thousand was taken from the source, refused by the destination, and
383
+ # sat nowhere until a human dispatched the reversal by hand ; banking's
384
+ # settlement left a debit standing with no credit and no compensation at all.
385
+ # Both bluebooks had written the compensating leg. Nothing armed it.
386
+ #
387
+ # A compensation that is itself refused does NOT unwind again, and needs no
388
+ # flag to stop it: the state moves to the compensating leg's to_state BEFORE
389
+ # its dispatches run, so a second refusal finds the instance no longer in
390
+ # from_state and records that instead. The check is the guard.
391
+ def unwind(pm, event, instance, correlation, domain)
392
+ handler = pm.handler_for(REFUSED)
393
+ return unless handler && instance
394
+
395
+ record = { process_manager: pm.name, on: REFUSED, instance: correlation }
396
+ pre_state = nil
397
+
398
+ # Same non-reentrancy reasoning as `advance_saga`'s own comment —
399
+ # the mutex covers only the check-and-mutate-and-checkpoint step.
400
+ advanced = @registry.saga_mutex.synchronize do
401
+ unless instance[:state] == handler.from_state
402
+ @registry.saga_log << record.merge(advanced: false,
403
+ reason: "in #{instance[:state].inspect}, not #{handler.from_state.inspect}")
404
+ next false
405
+ end
406
+
407
+ pre_state = instance[:state]
408
+ instance[:state] = handler.to_state
409
+ checkpoint(pm, correlation, instance, domain,
410
+ pending: pending_marker(event, handler, pre_state, instance[:state]))
411
+ true
412
+ end
413
+ return unless advanced
414
+
415
+ # See `settle_transition`'s own comment on `pre_state`/
416
+ # `instance[:state]` — the real observed transition, not a
417
+ # second read of the SAME handler object `Properties.saga_
418
+ # advances_follow_declared_handlers` checks this log against.
419
+ # `drain_compensations: true` — only `unwind`'s own call site
420
+ # fires derived compensation; `advance_saga`'s own call never
421
+ # does.
422
+ settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state,
423
+ drain_compensations: true)
424
+ end
425
+
426
+ # A DERIVED COMPENSATION — `entry[:args]` is already resolved
427
+ # (`record_completed_compensation`'s own comment for why), so this
428
+ # skips `dispatch_args` entirely and goes straight to delivery,
429
+ # through the SAME retry-on-defect path an ordinary forward leg
430
+ # uses. Never re-enters `unwind` on its own failure — a
431
+ # compensation that itself refuses is a real, pre-existing gap
432
+ # this feature makes visible rather than closes (see this file's
433
+ # own class-level notes); `compensation_failed: true` tags it
434
+ # distinctly in the log instead of recording it identically to an
435
+ # ordinary failed delivery, and every OTHER completed compensation
436
+ # still queued still gets its own attempt.
437
+ def deliver_derived_compensation(pm, entry, correlation, domain)
438
+ record = { process_manager: pm.name, instance: correlation, dispatch: entry[:command_name] }
439
+
440
+ attempt = 0
441
+ begin
442
+ invocation = ReactionInvocation.build(
443
+ registry: @registry,
444
+ verb: qualified(entry[:command_name], domain),
445
+ projected: entry[:args],
446
+ explicit: true,
447
+ passthrough: [pm.correlation_head],
448
+ source_receiver: nil
449
+ )
450
+ @door.reenter(qualified(entry[:command_name], domain),
451
+ saga_correlation: { pm.correlation_head.to_s => correlation }, **invocation)
452
+ @registry.saga_log << record.merge(delivered: true, compensation: true)
453
+ rescue *DOMAIN_REFUSALS => error
454
+ @registry.saga_log << record.merge(delivered: false, reason: error.message, compensation: true, compensation_failed: true)
455
+ rescue StandardError => error
456
+ attempt += 1
457
+ if attempt <= MAX_DEFECT_RETRIES
458
+ @registry.saga_log << record.merge(delivered: false, reason: error.message, compensation: true,
459
+ defect: true, error_class: error.class.name,
460
+ attempt: attempt, retrying: true)
461
+ retry
462
+ end
463
+
464
+ warn "[hecks] defect compensating saga #{pm.name} — instance #{correlation.inspect} " \
465
+ "dispatching #{entry[:command_name]} after #{attempt} attempts: #{error.class}: #{error.message}"
466
+ @registry.saga_log << record.merge(delivered: false, reason: error.message, compensation: true,
467
+ defect: true, error_class: error.class.name, compensation_failed: true)
468
+ end
469
+ end
470
+
471
+ def dispatch_args(pm, spec, event, instance, correlation)
472
+ ReactionInvocation.resolve_mapping(
473
+ with_spec: spec.with_spec,
474
+ scopes: [["current event payload", event.payload], ["opening event memory", instance[:memory]]],
475
+ bindings: { pm.correlation_head => correlation },
476
+ label: "#{pm.name}'s dispatch #{spec.command_name}"
477
+ )
478
+ end
479
+
480
+ def qualified(command_name, domain)
481
+ command_name.include?("::") ? command_name : "#{domain}::#{command_name}"
482
+ end
483
+
484
+ def end_saga(pm, event, domain)
485
+ return unless event.name == pm.ends_on
486
+
487
+ correlation = saga_correlation(pm, event)
488
+ return if correlation.to_s.empty?
489
+
490
+ ended = @registry.saga_mutex.synchronize do
491
+ next false unless @registry.saga_instances[pm.name].delete(correlation)
492
+
493
+ @registry.saga_persistence(domain).delete_saga(process_manager: pm.name, correlation: correlation)
494
+ true
495
+ end
496
+ return unless ended
497
+
498
+ @registry.saga_log << { process_manager: pm.name, on: event.name,
499
+ instance: correlation, ended: true }
500
+ end
501
+ end
502
+ end
503
+ end
@@ -0,0 +1,45 @@
1
+ module Hecks
2
+ module Runtime
3
+ # THE ONE SHARED CONSTANT between `SagaInterpreter` (the writer) and
4
+ # `Registry::SagaPersistence#rehydrate_sagas!` (the reader) for a
5
+ # scoped, minimal answer to the saga-durability review's item 8 (a
6
+ # durable outbox): a marker that survives exactly the window a crash
7
+ # in `advance_saga`/`unwind` can otherwise hide.
8
+ #
9
+ # THE PROBLEM THIS CLOSES — `checkpoint` persists a saga's new state
10
+ # BEFORE the leg that justifies it (`handler.dispatches`) runs, and
11
+ # deliberately so: the mutex it holds is not reentrant, and a
12
+ # dispatch can re-enter this same interpreter. If the process dies
13
+ # in that window, the store says the transition happened and there
14
+ # is no record that its dispatch(es) never ran — not a refusal (the
15
+ # domain never got asked), not a defect (nothing raised), just
16
+ # silence indistinguishable from a leg that finished cleanly.
17
+ #
18
+ # THE FIX — `checkpoint` now writes this key into the SAME already-
19
+ # durable `memory` blob (no new column, no adapter/schema change:
20
+ # `memory` is already an opaque, adapter-agnostic JSON blob every
21
+ # `save_saga` implementation round-trips verbatim) whenever it
22
+ # checkpoints a state a dispatch cascade hasn't run for YET, and
23
+ # clears it (a second checkpoint, `pending: nil`) once that cascade
24
+ # — success, refusal-compensated, defect-compensated, or ceiling-
25
+ # compensated — has actually run. A crash between those two writes
26
+ # leaves the marker standing; `rehydrate_sagas!` strips it back out
27
+ # of the LIVE instance's own `:memory` (so no dispatch/`given`/
28
+ # fuzzer/doc consumer of a saga's memory ever sees this key — it
29
+ # exists only in the persisted blob) and surfaces it loudly instead.
30
+ #
31
+ # WHAT THIS DELIBERATELY DOES NOT DO — auto-redrive the pending leg.
32
+ # Redelivering a dispatch whose outcome is genuinely unknown is only
33
+ # safe with idempotent delivery (the downstream command recognizing
34
+ # and no-op'ing a duplicate), which hecks's command/event pipeline
35
+ # has no mechanism for today. Blindly re-dispatching without that is
36
+ # how a stalled transfer becomes a DOUBLE-CREDITED one — a strictly
37
+ # worse defect than the stall it would replace. So this is real,
38
+ # durable, crash-surviving VISIBILITY into exactly what a stalled
39
+ # saga was doing when the process died — the missing half of "no
40
+ # reconciliation pass exists" — not the full pending → claimed →
41
+ # delivered outbox `future-features.md` still lists as unbuilt, and
42
+ # not a substitute for it.
43
+ SAGA_PENDING_DISPATCH_KEY = :__hecks_saga_pending_dispatch__
44
+ end
45
+ end
@@ -0,0 +1,84 @@
1
+ require_relative "../ports/persistence/binding_policy"
2
+
3
+ module Hecks
4
+ module Runtime
5
+ # The capability idiom for MULTI-TENANT hosting — mirrors EraCheck's
6
+ # own `lineage_capable?`, one level over. An adapter answers
7
+ # `tenant_capable?` with true when its OWN instances genuinely
8
+ # isolate one boot's data from another's, given each tenant is its
9
+ # own separate `Runtime.boot` call (its own Registry, its own
10
+ # Dispatcher, its own adapter instances) rather than one shared
11
+ # process switching connections mid-dispatch.
12
+ #
13
+ # THAT LAST PART IS THE FINDING THIS MODULE ENCODES. The project
14
+ # register (Bluebook::ProjectRegister) already resolves an address's
15
+ # REALM to a DISPATCHER at registration time — Router#resolve looks
16
+ # the FQN up in one flat table keyed by realm::domain::aggregate.verb,
17
+ # and each entry already carries its OWN dispatcher from its OWN
18
+ # boot. So "which tenant" is decided ONCE, at boot/registration time
19
+ # (which of possibly many boots of the same directory a request's
20
+ # realm resolves to), never per-dispatch inside a shared registry.
21
+ # No ambient thread-local "current tenant" is needed, and no
22
+ # connection cache is needed beyond what booting-once-per-tenant
23
+ # already gives for free — each tenant's own PostgresEra instance
24
+ # IS its own connection, held for the life of that boot.
25
+ #
26
+ # So `tenant_capable?` asks a narrower question than it might sound:
27
+ # not "can this adapter switch tenants," but "does booting this
28
+ # adapter twice, with different settings, for the same directory,
29
+ # actually keep the two boots' data apart." Memory answers true
30
+ # trivially — a `@records` Hash is a plain instance variable, and
31
+ # two `Runtime.boot` calls build two entirely separate Registry
32
+ # objects, so two Memory adapter instances never share state by
33
+ # construction. PostgresEra answers true because its own `schema:`
34
+ # setting (already built, already the Storehouse mechanism) puts
35
+ # each boot's tables in their own Postgres schema via `SET
36
+ # search_path` — proven for real, not assumed, by
37
+ # tenant_isolation_spec.rb. Plain Postgres (no schema story) and D1
38
+ # (no schema-equivalent at all — see world.bluebook's own comment on
39
+ # the lifeadelics D1 tradeoff) answer false, or don't answer at all,
40
+ # which this module treats identically to false.
41
+ module TenantCheck
42
+ module_function
43
+
44
+ # A domain is safe to boot for MORE THAN ONE TENANT only if every
45
+ # aggregate's resolved persistence adapter is tenant_capable? — one
46
+ # ungoverned adapter sharing state across two tenant boots is a
47
+ # real data leak, not a theoretical one, so this is checked before
48
+ # a second tenant boot of the same directory is trusted, the same
49
+ # severity EraCheck/refuse_ungoverned_roles! already hold their
50
+ # own gates to.
51
+ def refuse_unless_tenant_capable!(registry, domain)
52
+ bluebook = registry.bluebook(domain)
53
+ return unless bluebook
54
+
55
+ offender = bluebook.aggregates.find do |aggregate|
56
+ adapter_name = Ports::Persistence::BindingPolicy.resolve(registry, domain, aggregate).adapter
57
+ !tenant_capable?(registry, adapter_name)
58
+ end
59
+ return unless offender
60
+
61
+ adapter_name = Ports::Persistence::BindingPolicy.resolve(registry, domain, offender).adapter
62
+ raise WiringError,
63
+ "#{domain}::#{offender.hecks_name} is bound to #{adapter_name}, which is not " \
64
+ "tenant_capable? — booting #{domain} for more than one tenant would share " \
65
+ "#{adapter_name}'s own storage across tenants that must never see each other's data. " \
66
+ "Bind a tenant-capable adapter (Memory, PostgresEra with its own schema: per tenant), " \
67
+ "or keep #{domain} single-tenant."
68
+ end
69
+
70
+ # The capability idiom itself — an adapter CLASS that answers
71
+ # tenant_capable? with true keeps two boots' data apart by
72
+ # construction (Memory) or by an explicit per-boot isolation
73
+ # setting (PostgresEra's schema:). Same defensive shape
74
+ # EraCheck#lineage_capable? already uses: a class that doesn't
75
+ # respond at all is false, not an error.
76
+ def tenant_capable?(registry, adapter_name)
77
+ adapter_class = registry.adapters[adapter_name] && registry.adapter_class(adapter_name)
78
+ adapter_class.respond_to?(:tenant_capable?) && adapter_class.tenant_capable?
79
+ rescue StandardError
80
+ false
81
+ end
82
+ end
83
+ end
84
+ end