hecks 2.7.0 → 2.9.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 (463) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +11 -0
  3. data/lib/hecks/adapters/driven/claude_code.rb +4 -104
  4. data/lib/hecks/adapters/driven/d1.rb +44 -237
  5. data/lib/hecks/adapters/driven/folder.rb +17 -76
  6. data/lib/hecks/adapters/driven/google_authentication.rb +15 -49
  7. data/lib/hecks/adapters/driven/governance_authorization.rb +23 -70
  8. data/lib/hecks/adapters/driven/heki/journal.rb +8 -41
  9. data/lib/hecks/adapters/driven/heki/saga_store.rb +12 -54
  10. data/lib/hecks/adapters/driven/heki/snapshot.rb +4 -12
  11. data/lib/hecks/adapters/driven/heki.rb +13 -42
  12. data/lib/hecks/adapters/driven/identity_registry.rb +3 -14
  13. data/lib/hecks/adapters/driven/in_memory_ordering.rb +9 -26
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +2 -14
  15. data/lib/hecks/adapters/driven/lambda/client.rb +10 -51
  16. data/lib/hecks/adapters/driven/lambda.rb +10 -84
  17. data/lib/hecks/adapters/driven/local_storage.rb +5 -40
  18. data/lib/hecks/adapters/driven/memory.rb +18 -99
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +5 -21
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +6 -28
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +3 -12
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +4 -30
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +34 -108
  24. data/lib/hecks/adapters/driven/postgres.rb +145 -134
  25. data/lib/hecks/adapters/driven/postgres_era.adapter +4 -15
  26. data/lib/hecks/adapters/driven/prism.rb +7 -30
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +21 -121
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +8 -40
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +26 -82
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +40 -84
  31. data/lib/hecks/adapters/driven/sqlite.rb +128 -65
  32. data/lib/hecks/adapters/driven/tenant_provisioner.rb +15 -37
  33. data/lib/hecks/adapters/driven.rb +5 -36
  34. data/lib/hecks/adapters/driving/github_webhook.rb +8 -76
  35. data/lib/hecks/behaviors/dsl.rb +7 -25
  36. data/lib/hecks/behaviors/expectations.rb +32 -142
  37. data/lib/hecks/behaviors/ir.rb +7 -20
  38. data/lib/hecks/behaviors/rspec.rb +6 -17
  39. data/lib/hecks/behaviors/runner.rb +10 -34
  40. data/lib/hecks/behaviors.rb +4 -8
  41. data/lib/hecks/bluebook/aggregate.rb +9 -75
  42. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +9 -38
  43. data/lib/hecks/bluebook/assembly/build.rb +4 -15
  44. data/lib/hecks/bluebook/assembly/contract.rb +15 -136
  45. data/lib/hecks/bluebook/assembly/contracts.rb +54 -174
  46. data/lib/hecks/bluebook/assembly/marks.rb +31 -223
  47. data/lib/hecks/bluebook/assembly/specializer.rb +6 -52
  48. data/lib/hecks/bluebook/assembly.rb +6 -45
  49. data/lib/hecks/bluebook/attribute.rb +12 -61
  50. data/lib/hecks/bluebook/behaviour/aggregate.rb +10 -39
  51. data/lib/hecks/bluebook/behaviour/attribute.rb +3 -14
  52. data/lib/hecks/bluebook/behaviour/chapter.rb +17 -42
  53. data/lib/hecks/bluebook/behaviour/command.rb +14 -66
  54. data/lib/hecks/bluebook/behaviour/domain_port.rb +4 -20
  55. data/lib/hecks/bluebook/behaviour/entity.rb +7 -27
  56. data/lib/hecks/bluebook/behaviour/hexagon.rb +23 -41
  57. data/lib/hecks/bluebook/behaviour/lifecycle.rb +15 -40
  58. data/lib/hecks/bluebook/behaviour/policy.rb +23 -52
  59. data/lib/hecks/bluebook/behaviour/process_manager.rb +30 -57
  60. data/lib/hecks/bluebook/behaviour/read_model.rb +38 -26
  61. data/lib/hecks/bluebook/behaviour/traits.rb +27 -64
  62. data/lib/hecks/bluebook/behaviour/value_object.rb +5 -24
  63. data/lib/hecks/bluebook/capabilities.rb +17 -49
  64. data/lib/hecks/bluebook/chapter.rb +10 -48
  65. data/lib/hecks/bluebook/command.rb +23 -81
  66. data/lib/hecks/bluebook/domain_port.rb +14 -63
  67. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +50 -229
  68. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +50 -377
  69. data/lib/hecks/bluebook/dsl/attribute_collector.rb +33 -204
  70. data/lib/hecks/bluebook/dsl/binding_proxy.rb +17 -65
  71. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +86 -708
  72. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +56 -170
  73. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  74. data/lib/hecks/bluebook/dsl/command_builder.rb +74 -546
  75. data/lib/hecks/bluebook/dsl/const_shim.rb +8 -41
  76. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +12 -87
  77. data/lib/hecks/bluebook/dsl/entity_builder.rb +44 -330
  78. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +20 -269
  79. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +26 -189
  80. data/lib/hecks/bluebook/dsl/identity_declaration.rb +13 -88
  81. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +6 -20
  82. data/lib/hecks/bluebook/dsl/policy_builder.rb +16 -88
  83. data/lib/hecks/bluebook/dsl/port_builder.rb +2 -6
  84. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +22 -96
  85. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +31 -184
  86. data/lib/hecks/bluebook/dsl/query_builder.rb +11 -42
  87. data/lib/hecks/bluebook/dsl/read_model_builder.rb +75 -201
  88. data/lib/hecks/bluebook/dsl/rule_reference.rb +22 -163
  89. data/lib/hecks/bluebook/dsl/translation_builder.rb +28 -114
  90. data/lib/hecks/bluebook/dsl/value_object_builder.rb +10 -124
  91. data/lib/hecks/bluebook/dsl/word_gate.rb +16 -162
  92. data/lib/hecks/bluebook/dsl/world_builder.rb +19 -106
  93. data/lib/hecks/bluebook/entity.rb +13 -71
  94. data/lib/hecks/bluebook/expression/ast_json.rb +24 -190
  95. data/lib/hecks/bluebook/expression/ast_reader.rb +12 -54
  96. data/lib/hecks/bluebook/expression/canonical_form.rb +11 -88
  97. data/lib/hecks/bluebook/expression/evaluator.rb +47 -295
  98. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +30 -147
  99. data/lib/hecks/bluebook/expression/resolver.rb +37 -660
  100. data/lib/hecks/bluebook/hexagon.rb +5 -28
  101. data/lib/hecks/bluebook/lifecycle.rb +5 -16
  102. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -7
  103. data/lib/hecks/bluebook/meta_validator/judge.rb +111 -361
  104. data/lib/hecks/bluebook/meta_validator/plan.rb +33 -162
  105. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -7
  106. data/lib/hecks/bluebook/meta_validator/readings.rb +56 -342
  107. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +24 -176
  108. data/lib/hecks/bluebook/meta_validator/shapes.rb +54 -268
  109. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +47 -308
  110. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +7 -43
  111. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -18
  112. data/lib/hecks/bluebook/meta_validator.rb +79 -349
  113. data/lib/hecks/bluebook/model_check/client_profile.rb +10 -91
  114. data/lib/hecks/bluebook/model_check.rb +102 -600
  115. data/lib/hecks/bluebook/pattern_subset.rb +11 -69
  116. data/lib/hecks/bluebook/process_manager.rb +13 -83
  117. data/lib/hecks/bluebook/project_loader.rb +3 -39
  118. data/lib/hecks/bluebook/project_register.rb +12 -60
  119. data/lib/hecks/bluebook/query.rb +9 -48
  120. data/lib/hecks/bluebook/read_model.rb +57 -81
  121. data/lib/hecks/bluebook/reference.rb +7 -41
  122. data/lib/hecks/bluebook/smoke_test.rb +8 -98
  123. data/lib/hecks/bluebook/synthesizer.rb +13 -62
  124. data/lib/hecks/bluebook/translation.rb +26 -62
  125. data/lib/hecks/bluebook/value_object.rb +11 -36
  126. data/lib/hecks/bluebook.rb +5 -22
  127. data/lib/hecks/cache_dir.rb +84 -0
  128. data/lib/hecks/cli/document.rb +86 -0
  129. data/lib/hecks/cli/ir.rb +45 -0
  130. data/lib/hecks/cli/mcp.rb +31 -0
  131. data/lib/hecks/cli/mcp_door.rb +366 -0
  132. data/lib/hecks/cli/model_check.rb +203 -0
  133. data/lib/hecks/cli/project_cli.rb +90 -0
  134. data/lib/hecks/cli/project_diagrams.rb +36 -0
  135. data/lib/hecks/cli/run.rb +173 -0
  136. data/lib/hecks/cli/smoke_test.rb +45 -0
  137. data/lib/hecks/cli/stores.rb +53 -0
  138. data/lib/hecks/cli.rb +160 -0
  139. data/lib/hecks/construct.rb +8 -34
  140. data/lib/hecks/deploy/bluebook/deploy.bluebook +33 -142
  141. data/lib/hecks/deploy/bluebook/deploy.hecksagon +2 -9
  142. data/lib/hecks/embryonaut_bluebook/lock.rb +3 -35
  143. data/lib/hecks/embryonaut_bluebook/shape.rb +1 -17
  144. data/lib/hecks/embryonaut_bluebook/vendor.rb +8 -53
  145. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +4 -19
  146. data/lib/hecks/embryonaut_bluebook.rb +4 -71
  147. data/lib/hecks/facade/cli_door.rb +24 -106
  148. data/lib/hecks/facade/cli_runner.rb +28 -172
  149. data/lib/hecks/facade/command_request.rb +13 -33
  150. data/lib/hecks/facade/handle.rb +34 -121
  151. data/lib/hecks/facade/json_door.rb +31 -146
  152. data/lib/hecks/facade/surface/aggregate_door.rb +39 -104
  153. data/lib/hecks/facade/surface/chapter.rb +10 -54
  154. data/lib/hecks/facade/surface.rb +8 -23
  155. data/lib/hecks/facade.rb +10 -25
  156. data/lib/hecks/forms/app.rb +15 -70
  157. data/lib/hecks/forms/command_form_renderer.rb +10 -29
  158. data/lib/hecks/forms/field_renderer.rb +17 -156
  159. data/lib/hecks/forms/field_shape.rb +30 -222
  160. data/lib/hecks/forms/html.rb +12 -47
  161. data/lib/hecks/forms/index_renderer.rb +1 -4
  162. data/lib/hecks/forms/page.rb +3 -9
  163. data/lib/hecks/forms/params.rb +14 -60
  164. data/lib/hecks/forms/port_argument.rb +2 -19
  165. data/lib/hecks/forms/query_form_renderer.rb +4 -79
  166. data/lib/hecks/forms/record_renderer.rb +8 -75
  167. data/lib/hecks/forms/record_table.rb +7 -50
  168. data/lib/hecks/forms/reference_options.rb +3 -30
  169. data/lib/hecks/forms/value_object_shape.rb +8 -35
  170. data/lib/hecks/forms.rb +5 -33
  171. data/lib/hecks/fqn.rb +5 -12
  172. data/lib/hecks/framework/bluebook/compliance.bluebook +16 -65
  173. data/lib/hecks/framework/bluebook/console_settings.bluebook +47 -134
  174. data/lib/hecks/framework/bluebook/framework.hecksagon +5 -14
  175. data/lib/hecks/framework/bluebook/governance.bluebook +13 -34
  176. data/lib/hecks/framework/bluebook/identity.bluebook +11 -26
  177. data/lib/hecks/framework/bluebook/privacy.bluebook +8 -33
  178. data/lib/hecks/framework.rb +11 -80
  179. data/lib/hecks/freezer.rb +5 -39
  180. data/lib/hecks/grammar/expression.bluebook +15 -40
  181. data/lib/hecks/grammar/translation.bluebook +3 -6
  182. data/lib/hecks/grammar.rb +16 -41
  183. data/lib/hecks/ir.rb +14 -83
  184. data/lib/hecks/language/adapter.bluebook +2 -7
  185. data/lib/hecks/language/bluebook/aggregate.bluebook +56 -221
  186. data/lib/hecks/language/bluebook/attaches/paging.bluebook +6 -15
  187. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -83
  188. data/lib/hecks/language/bluebook/bluebook.hecksagon +3 -15
  189. data/lib/hecks/language/bluebook/command.bluebook +27 -144
  190. data/lib/hecks/language/bluebook/entity.bluebook +24 -97
  191. data/lib/hecks/language/bluebook/policy.bluebook +16 -67
  192. data/lib/hecks/language/bluebook/process_manager.bluebook +28 -151
  193. data/lib/hecks/language/bluebook/projection.bluebook +126 -60
  194. data/lib/hecks/language/bluebook/query.bluebook +12 -43
  195. data/lib/hecks/language/bluebook/shape.bluebook +23 -73
  196. data/lib/hecks/language/bluebook/syntax.bluebook +51 -262
  197. data/lib/hecks/language/bluebook/vocabulary.bluebook +98 -197
  198. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +3 -10
  199. data/lib/hecks/language/hecksagon/domain_port.bluebook +4 -19
  200. data/lib/hecks/language/hecksagon/hecksagon.bluebook +6 -25
  201. data/lib/hecks/language/hecksagon/port_operation.bluebook +3 -14
  202. data/lib/hecks/language/oidc.json +35 -0
  203. data/lib/hecks/language/port.bluebook +4 -17
  204. data/lib/hecks/language/translation/translation.bluebook +3 -9
  205. data/lib/hecks/language/translation/translation_aggregate.bluebook +5 -24
  206. data/lib/hecks/language/world/wiring.bluebook +1 -3
  207. data/lib/hecks/language/world/world.bluebook +5 -7
  208. data/lib/hecks/literal.rb +8 -50
  209. data/lib/hecks/mcp_door_scope.rb +95 -0
  210. data/lib/hecks/mcp_stdio_guard.rb +4 -41
  211. data/lib/hecks/naming.rb +29 -137
  212. data/lib/hecks/ports/access_control.rb +7 -23
  213. data/lib/hecks/ports/agent/answers.rb +5 -23
  214. data/lib/hecks/ports/agent.rb +47 -172
  215. data/lib/hecks/ports/authentication.rb +12 -39
  216. data/lib/hecks/ports/authorization.rb +16 -43
  217. data/lib/hecks/ports/clock.rb +5 -45
  218. data/lib/hecks/ports/extraction.rb +7 -12
  219. data/lib/hecks/ports/identity_assignment.rb +8 -29
  220. data/lib/hecks/ports/identity_generation.rb +6 -29
  221. data/lib/hecks/ports/identity_resolution.rb +2 -10
  222. data/lib/hecks/ports/key_vault.rb +9 -45
  223. data/lib/hecks/ports/persistence/append_only.rb +62 -65
  224. data/lib/hecks/ports/persistence/binding_policy.rb +4 -14
  225. data/lib/hecks/ports/persistence/codec_boundary.rb +20 -80
  226. data/lib/hecks/ports/persistence/null_saga_store.rb +4 -20
  227. data/lib/hecks/ports/persistence/plugin.rb +13 -42
  228. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +38 -141
  229. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +15 -115
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +16 -84
  231. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +7 -26
  232. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -22
  233. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +55 -181
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/events.rb +77 -0
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +12 -122
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +28 -128
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +73 -474
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +31 -124
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +45 -219
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +13 -108
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +18 -75
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +9 -49
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +6 -123
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +23 -55
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -72
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +7 -16
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +14 -115
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +13 -45
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +127 -554
  250. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +13 -63
  251. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +2 -17
  252. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +3 -14
  253. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +13 -58
  254. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -17
  255. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +9 -26
  256. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +9 -25
  257. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +22 -122
  258. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +34 -123
  259. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +5 -12
  260. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +3 -9
  261. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -14
  262. data/lib/hecks/ports/persistence/plugins/era.rb +4 -23
  263. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +7 -23
  264. data/lib/hecks/ports/persistence/postgres_dump.rb +4 -35
  265. data/lib/hecks/ports/persistence/remote_runtime.rb +2 -24
  266. data/lib/hecks/ports/persistence/repository_factory.rb +8 -27
  267. data/lib/hecks/ports/persistence/state_codec.rb +23 -103
  268. data/lib/hecks/ports/persistence.rb +7 -14
  269. data/lib/hecks/ports/projection.rb +42 -84
  270. data/lib/hecks/ports/query/in_memory.rb +10 -58
  271. data/lib/hecks/ports/query/ordering.rb +11 -32
  272. data/lib/hecks/ports/query.rb +14 -37
  273. data/lib/hecks/ports.rb +2 -8
  274. data/lib/hecks/projections/bootstrap_table.rb +11 -43
  275. data/lib/hecks/projections/deploy/fargate/assembly.rb +2 -15
  276. data/lib/hecks/projections/deploy/fargate/cdn.rb +1 -48
  277. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +2 -11
  278. data/lib/hecks/projections/deploy/fargate/check.rb +5 -14
  279. data/lib/hecks/projections/deploy/fargate/containers.rb +3 -78
  280. data/lib/hecks/projections/deploy/fargate/extras.rb +3 -44
  281. data/lib/hecks/projections/deploy/fargate/monitoring.rb +0 -44
  282. data/lib/hecks/projections/deploy/fargate/settings.rb +2 -54
  283. data/lib/hecks/projections/deploy/fargate/yaml.rb +13 -58
  284. data/lib/hecks/projections/deploy/fargate.rb +35 -202
  285. data/lib/hecks/projections/deploy/lambda.rb +151 -679
  286. data/lib/hecks/projections/deploy/preview/access_yaml.rb +1 -33
  287. data/lib/hecks/projections/deploy/preview/containers.rb +6 -111
  288. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +4 -56
  289. data/lib/hecks/projections/deploy/preview/script.rb +2 -14
  290. data/lib/hecks/projections/deploy/preview/settings.rb +3 -33
  291. data/lib/hecks/projections/deploy/preview/task_yaml.rb +2 -38
  292. data/lib/hecks/projections/deploy/preview/template.rb +2 -58
  293. data/lib/hecks/projections/deploy/preview.rb +2 -67
  294. data/lib/hecks/projections/deploy/scripts/settings.rb +3 -25
  295. data/lib/hecks/projections/deploy/scripts.rb +4 -57
  296. data/lib/hecks/projections/deploy/shared.rb +47 -146
  297. data/lib/hecks/projections/deploy/smoke.rb +4 -61
  298. data/lib/hecks/projections/deploy/template_diff/comparer.rb +3 -25
  299. data/lib/hecks/projections/deploy/template_diff/loader.rb +2 -11
  300. data/lib/hecks/projections/deploy/template_diff.rb +5 -57
  301. data/lib/hecks/projections/diagrams.rb +20 -582
  302. data/lib/hecks/projections/glossary/html.rb +14 -130
  303. data/lib/hecks/projections/glossary/markdown.rb +8 -69
  304. data/lib/hecks/projections/glossary/mermaid.rb +9 -66
  305. data/lib/hecks/projections/glossary/sensitivity.rb +5 -34
  306. data/lib/hecks/projections/glossary/sentences.rb +14 -159
  307. data/lib/hecks/projections/glossary.rb +30 -222
  308. data/lib/hecks/projections/ir.rb +3 -10
  309. data/lib/hecks/projections/model/deviations.rb +15 -42
  310. data/lib/hecks/projections/model.rb +12 -51
  311. data/lib/hecks/projections/oidc.rb +12 -56
  312. data/lib/hecks/projections/parser_table.rb +9 -28
  313. data/lib/hecks/projections/reference.rb +9 -16
  314. data/lib/hecks/projections/rust_vocabulary.rb +12 -250
  315. data/lib/hecks/projections/shape.rb +3 -29
  316. data/lib/hecks/projections/statements.rb +7 -86
  317. data/lib/hecks/projections/vocabulary.rb +6 -22
  318. data/lib/hecks/projections.rb +2 -16
  319. data/lib/hecks/projector/cli_projector.rb +60 -375
  320. data/lib/hecks/projector/docs_projector.rb +10 -201
  321. data/lib/hecks/projector/exporter.rb +46 -138
  322. data/lib/hecks/projector/ir_projector.rb +2 -9
  323. data/lib/hecks/projector/narrate_projector.rb +15 -189
  324. data/lib/hecks/projector/target.rb +13 -76
  325. data/lib/hecks/projector.rb +30 -121
  326. data/lib/hecks/query_specification/common/comparators.rb +5 -26
  327. data/lib/hecks/query_specification/common/comparison.rb +35 -143
  328. data/lib/hecks/query_specification/common/dsl.rb +8 -24
  329. data/lib/hecks/query_specification/common/null_policy.rb +20 -70
  330. data/lib/hecks/query_specification/common/options.rb +2 -6
  331. data/lib/hecks/query_specification/common/where_clause.rb +2 -10
  332. data/lib/hecks/query_specification/field_path.rb +37 -77
  333. data/lib/hecks/query_specification/hop_path.rb +12 -76
  334. data/lib/hecks/release/gem_pin.rb +2 -26
  335. data/lib/hecks/release/runner/ci_publisher.rb +93 -0
  336. data/lib/hecks/release/runner/commands.rb +66 -0
  337. data/lib/hecks/release/runner/console.rb +52 -0
  338. data/lib/hecks/release/runner/gem_publisher.rb +43 -0
  339. data/lib/hecks/release/runner/git.rb +48 -0
  340. data/lib/hecks/release/runner/npm_publisher.rb +75 -0
  341. data/lib/hecks/release/runner/preflight.rb +104 -0
  342. data/lib/hecks/release/runner/published.rb +55 -0
  343. data/lib/hecks/release/runner/tagger.rb +80 -0
  344. data/lib/hecks/release/runner.rb +215 -0
  345. data/lib/hecks/rendering.rb +4 -26
  346. data/lib/hecks/router/namespace_installer.rb +12 -36
  347. data/lib/hecks/router.rb +4 -8
  348. data/lib/hecks/runtime/aggregate_lock.rb +8 -33
  349. data/lib/hecks/runtime/boot_gates.rb +3 -18
  350. data/lib/hecks/runtime/caller.rb +19 -59
  351. data/lib/hecks/runtime/capability_graph.rb +5 -21
  352. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +16 -78
  353. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +23 -160
  354. data/lib/hecks/runtime/command_interpreter.rb +125 -307
  355. data/lib/hecks/runtime/command_rules/admissibility.rb +95 -366
  356. data/lib/hecks/runtime/command_rules/arithmetic.rb +66 -196
  357. data/lib/hecks/runtime/command_rules/authorization.rb +13 -45
  358. data/lib/hecks/runtime/command_rules/emission.rb +8 -21
  359. data/lib/hecks/runtime/command_rules/references.rb +23 -202
  360. data/lib/hecks/runtime/dependency_planning.rb +30 -102
  361. data/lib/hecks/runtime/dispatcher.rb +82 -241
  362. data/lib/hecks/runtime/entity_element.rb +40 -462
  363. data/lib/hecks/runtime/entity_interpreter.rb +65 -220
  364. data/lib/hecks/runtime/errors.rb +32 -115
  365. data/lib/hecks/runtime/event.rb +3 -25
  366. data/lib/hecks/runtime/identity.rb +18 -154
  367. data/lib/hecks/runtime/instance.rb +18 -150
  368. data/lib/hecks/runtime/interpreting.rb +11 -70
  369. data/lib/hecks/runtime/invocation.rb +28 -164
  370. data/lib/hecks/runtime/loader.rb +48 -186
  371. data/lib/hecks/runtime/outbox.rb +38 -261
  372. data/lib/hecks/runtime/policy_interpreter.rb +38 -200
  373. data/lib/hecks/runtime/port_operation_interpreter.rb +26 -87
  374. data/lib/hecks/runtime/query_interpreter.rb +45 -187
  375. data/lib/hecks/runtime/reaction_invocation.rb +36 -138
  376. data/lib/hecks/runtime/read_model_interpreter.rb +139 -208
  377. data/lib/hecks/runtime/rebuild_sweep.rb +8 -54
  378. data/lib/hecks/runtime/reference_hop.rb +8 -81
  379. data/lib/hecks/runtime/refusal_wording.rb +8 -89
  380. data/lib/hecks/runtime/registry/saga_persistence.rb +10 -102
  381. data/lib/hecks/runtime/registry/verification.rb +52 -225
  382. data/lib/hecks/runtime/registry/world_defaults.rb +4 -53
  383. data/lib/hecks/runtime/registry.rb +103 -244
  384. data/lib/hecks/runtime/remote_dispatcher.rb +37 -152
  385. data/lib/hecks/runtime/routing.rb +4 -15
  386. data/lib/hecks/runtime/saga_interpreter/correlation.rb +14 -61
  387. data/lib/hecks/runtime/saga_interpreter.rb +79 -263
  388. data/lib/hecks/runtime/saga_pending_dispatch.rb +2 -43
  389. data/lib/hecks/runtime/tenant_check.rb +6 -68
  390. data/lib/hecks/runtime/tenant_scope.rb +3 -28
  391. data/lib/hecks/runtime/value/admission.rb +16 -67
  392. data/lib/hecks/runtime/value/coercion.rb +155 -641
  393. data/lib/hecks/runtime/value/entity_list_coercion.rb +26 -219
  394. data/lib/hecks/runtime/value.rb +12 -84
  395. data/lib/hecks/runtime.rb +17 -65
  396. data/lib/hecks/storehouse.rb +146 -567
  397. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +8 -32
  398. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +6 -23
  399. data/lib/hecks/vendoring/git_environment.rb +2 -8
  400. data/lib/hecks/vendoring/git_source.rb +4 -10
  401. data/lib/hecks/vendoring.rb +7 -46
  402. data/lib/hecks/version.rb +3 -14
  403. data/lib/hecks/vocabulary.rb +5 -0
  404. data/lib/hecks.rb +32 -82
  405. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +8 -46
  406. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +11 -80
  407. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +10 -99
  408. metadata +30 -59
  409. data/lib/hecks/bench/cli.rb +0 -74
  410. data/lib/hecks/bench/environment.rb +0 -100
  411. data/lib/hecks/bench/postgres_probe.rb +0 -41
  412. data/lib/hecks/bench/report.rb +0 -116
  413. data/lib/hecks/bench/ruby_runner.rb +0 -151
  414. data/lib/hecks/bench/run.rb +0 -55
  415. data/lib/hecks/bench/rust_runner.rb +0 -165
  416. data/lib/hecks/bench/stats.rb +0 -75
  417. data/lib/hecks/bench/suite.rb +0 -172
  418. data/lib/hecks/bench/workload.rb +0 -137
  419. data/lib/hecks/bench.rb +0 -28
  420. data/lib/hecks/codemod.rb +0 -443
  421. data/lib/hecks/corpus.rb +0 -479
  422. data/lib/hecks/doc/reference.rb +0 -585
  423. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  424. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  425. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  426. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  427. data/lib/hecks/fuzzing/differential.rb +0 -192
  428. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  429. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  430. data/lib/hecks/fuzzing/form_census.rb +0 -285
  431. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  432. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  433. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  434. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  435. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  436. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  437. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  438. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  439. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  440. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  441. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  442. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  443. data/lib/hecks/fuzzing/properties.rb +0 -267
  444. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  445. data/lib/hecks/fuzzing/replay.rb +0 -930
  446. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  447. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  448. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  449. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  450. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  451. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  452. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  453. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  454. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  455. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  456. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  457. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  458. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  459. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  460. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  461. data/lib/hecks/fuzzing.rb +0 -23
  462. data/lib/hecks/grammar/evolve.rb +0 -524
  463. data/lib/hecks/query_ir.rb +0 -482
@@ -1,825 +0,0 @@
1
- require "json"
2
- require "tmpdir"
3
- require "open3"
4
- require_relative "../adapters/driven/heki"
5
- require_relative "../runtime/saga_interpreter"
6
-
7
- module Hecks
8
- module Fuzzing
9
- # **A second comparison axis** — every other check this practice runs is
10
- # differential: Ruby vs the compiled Rust conformance binary, on the
11
- # same generated sequence (`bin/qa_sweep`'s own `diff_ruby_vs_rust`,
12
- # `Properties.check`). Differential comparison structurally cannot
13
- # catch a bug where both engines are wrong the same way, or where a
14
- # single engine is self-inconsistent with nothing to differentially
15
- # compare it against. This module asks a different question of one
16
- # engine at a time: does it agree with itself?
17
- #
18
- # ## Three checks
19
- #
20
- # Run on the same already-generated sequence and its resulting live
21
- # state — no second fuzzing pass, no re-dispatch through the command
22
- # layer:
23
- #
24
- # 1. `check_rehydration` — does reloading an aggregate from its
25
- # own durable journal reproduce exactly the state a live dispatch
26
- # already produced?
27
- # 2. `check_idempotency` — does replaying that same journal a
28
- # second time change anything? (a variant of #1, but a different
29
- # failure mode: no leaked state between applications, not just
30
- # "cold load works once.")
31
- # 3. `check_value_object_round_trip` — does every value object the
32
- # sequence actually built survive `to_json` then rebuilt back?
33
- #
34
- # ## The rehydration path, found, not guessed
35
- #
36
- # hecks is not event-sourced at the aggregate level — there is no
37
- # `AccountOpened`-shaped log a `CommandInterpreter` folds to rebuild
38
- # state. What there is, real and already shipping, is
39
- # `Ports::Persistence::AppendOnly` (lib/hecks/ports/persistence/
40
- # append_only.rb): every adapter accepts the same `Entry` stream
41
- # (`operation`, `id`, the full state after that command — not a
42
- # delta) and answers `#entries`; `#recover!` — "an append is durable
43
- # before a projection is attempted; replaying the log restores a
44
- # snapshot/table after a crash in that small window" — is called on
45
- # every repository this runtime ever builds (`RepositoryFactory
46
- # .build`'s own `recover: true` default). That is the production
47
- # cold-rehydration path. Reusing `#recover!` directly against the
48
- # live adapter would prove nothing, though: `Fuzzing::Replay` runs
49
- # against `Adapters::Memory` (`IsolatedBoot`'s own default), and
50
- # Memory's own `Entry#state` is a shallow `instance.state.dup` — the
51
- # exact same `Runtime::Value` objects a command produced ride along
52
- # unchanged, so folding them straight back through Memory's own
53
- # `#project` is a tautology that can never fail (`Value#for_attribute`
54
- # 's own `value.is_a?(self) && value.type_name == ...` branch passes
55
- # an already-typed value straight through, no rebuilding at all).
56
- #
57
- # So `fold!`, below, feeds the same entries through `Adapters::Heki`
58
- # instead — a real, already-shipped, disk-backed adapter
59
- # (examples/banking's own `persisted_by("Heki")`), in a throwaway
60
- # directory. Writing forces every value through `JSON.generate`
61
- # (Heki's own journal line, its own compressed snapshot); reading
62
- # back through a fresh `Heki` instance (unmemoized `@store`) forces
63
- # `read_snapshot`/`replay_journal` — real `JSON.parse`, real
64
- # `Zlib::Inflate`, real bytes off a real filesystem — which is what
65
- # makes `Instance.hydrate_with_defaults` → `Value.hydrate` →
66
- # `Value.for_attribute` actually rebuild every value object from raw
67
- # data via `Value.build`, the same coercion/validation path a real
68
- # restart takes, rather than pass the live object through unchanged.
69
- # This is the exact mechanism `AppendOnly#recover!` names in its own
70
- # comment ("restores a snapshot/table after a crash"), just exercised
71
- # against the one adapter whose own `#entries` actually forces the
72
- # JSON boundary Memory's does not.
73
- module SelfConsistency
74
- module_function
75
-
76
- # Every Heki this pass opens is guarded like a runtime repository
77
- # (Phase 2, Track A, PR A4). These adapters are built directly, not
78
- # through `RepositoryFactory.build`, so without this a cold read here
79
- # could hand `Runtime::Instance` undecoded state and the codec's
80
- # guarantee would have a bypass in exactly the check that exists to
81
- # compare stored state against live state.
82
- #
83
- # @param aggregate [Bluebook::Aggregate] the aggregate this store holds
84
- # @param settings [Hash] adapter settings, forwarded to `Adapters::Heki.new`
85
- # @param root [String, nil] the directory this store reads/writes under
86
- # @return [Adapters::Heki] a fresh, codec-boundary-guarded Heki instance
87
- def guarded_heki(**) = Ports::Persistence::CodecBoundary.guard!(Adapters::Heki.new(**))
88
-
89
- # The whole pass — called once, with the runtime still live (inside
90
- # `Replay.call`'s own `IsolatedBoot.call` block, before the tmp
91
- # directory and its adapters go out of scope) and the `history`
92
- # `Replay.call` is about to return. Nothing here boots a second
93
- # runtime or dispatches a single command; every check below reads
94
- # data this one replay already produced. Just the three checks
95
- # below, run and collected — kept as three independently callable
96
- # methods (not fused into one shared fold) so a spec proving one
97
- # check can fire never has to reason about the other two.
98
- #
99
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
100
- # booted runtime this replay just dispatched through
101
- # @param history [Hash] the replayed history `Replay.call` is about to return
102
- # @return [Hash{Symbol => Array<Hash>}] the five checks' own findings, keyed
103
- # `:rehydration`, `:idempotency`, `:value_object_round_trip`,
104
- # `:saga_rehydration`, `:saga_redelivery_idempotency` — each value an empty
105
- # Array when that check found nothing
106
- def check(runtime, history)
107
- { rehydration: check_rehydration(runtime), idempotency: check_idempotency(runtime),
108
- value_object_round_trip: check_value_object_round_trip(history),
109
- saga_rehydration: check_saga_rehydration(runtime, history),
110
- saga_redelivery_idempotency: check_saga_idempotency(runtime, history) }
111
- end
112
-
113
- # Check 1 — rehydrate-from-journal == live state.
114
- #
115
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
116
- # booted runtime this replay just dispatched through
117
- # @return [Array<Hash>] one divergence Hash (`:field`, `:domain`, `:aggregate`,
118
- # `:live`, `:rehydrated`) per aggregate whose cold-read state disagrees with its
119
- # live state; empty when every touched aggregate rehydrates cleanly
120
- def check_rehydration(runtime)
121
- each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
122
- live = snapshot(repository)
123
- Dir.mktmpdir("hecks-self-consistency") do |tmp|
124
- writer = guarded_heki(aggregate: aggregate, root: tmp)
125
- rehydrated = fold!(writer, tmp, aggregate, entries)
126
- next if rehydrated == live
127
-
128
- { field: "rehydration", domain: domain_name, aggregate: aggregate.hecks_name,
129
- live: live, rehydrated: rehydrated }
130
- end
131
- end
132
- end
133
-
134
- # Check 2 — replay idempotency: folding the same entries into the
135
- # same durable store a second time must change nothing. A variant
136
- # of check 1, worth stating separately — this catches a
137
- # replay-specific bug (leaked state between applications, a
138
- # double-applied effect) that a single, one-shot cold read could
139
- # never see, even one that already agrees with live state.
140
- #
141
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
142
- # booted runtime this replay just dispatched through
143
- # @return [Array<Hash>] one divergence Hash (`:field`, `:domain`, `:aggregate`,
144
- # `:once`, `:twice`) per aggregate whose second fold disagrees with its first;
145
- # empty when every touched aggregate folds idempotently
146
- def check_idempotency(runtime)
147
- each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
148
- Dir.mktmpdir("hecks-self-consistency") do |tmp|
149
- writer = guarded_heki(aggregate: aggregate, root: tmp)
150
- once = fold!(writer, tmp, aggregate, entries)
151
- twice = fold!(writer, tmp, aggregate, entries)
152
- next if once == twice
153
-
154
- { field: "idempotency", domain: domain_name, aggregate: aggregate.hecks_name,
155
- once: once, twice: twice }
156
- end
157
- end
158
- end
159
-
160
- # Check 3 — every `Runtime::Value` the sequence actually built
161
- # (walked out of the replay's own `instances`/`events`/`queries`,
162
- # never a hand-picked example), round-tripped through the real
163
- # serialize/deserialize pair: `Value#to_json` (JSON.generate(to_h),
164
- # value.rb) out, `Value.build` (value/coercion.rb — the same
165
- # constructor a command argument's own raw JSON goes through) back
166
- # in. There is no class-level `VO.from_json` in this codebase (that
167
- # spelling is Rust's — rust/src/exemplar/json.rs's generated
168
- # `from_json` per closed set/value object); `Value.build` is the
169
- # actual Ruby door a raw, untyped Hash becomes a validated,
170
- # admitted, invariant-checked value object through.
171
- # `aggregate:` threaded alongside every value found, not dropped —
172
- # `Value.build(value_object, fields, aggregate)`'s third argument is
173
- # what lets `normalize_composite_fields` resolve a nested composite
174
- # field's own type by name (`value_object_for(aggregate, type)`).
175
- # Building with `aggregate: nil` (this method's first version, live-
176
- # tested against `examples/pizzas` while this was being written)
177
- # silently skips that step entirely — `Pizza`'s own `price_cents`/
178
- # `size` fields round-tripped back as bare, string-keyed Hashes
179
- # instead of rebuilt `Money`/`PizzaSize` value objects, a false
180
- # positive this check would have reported as a real bug on every
181
- # single sweep. Resolved from `history[:instances]`' own key
182
- # (`"Domain::Aggregate#id"`, `Replay#snapshot_instances`) and
183
- # `history[:events]`' own `event[:aggregate]` (`"Domain::Aggregate"`,
184
- # domain-qualified) against `history[:bluebooks]` — the exact same
185
- # loaded chapter map every other replay-time check already reads
186
- # off `history` rather than a second lookup. `history[:queries]`'
187
- # own rows have no single owning aggregate reliably named on the
188
- # entry itself (a cross-aggregate read model, a `for_each` target),
189
- # so they are left out of this walk rather than risk the same false
190
- # positive `nil` already produced once — `instances` and `events`
191
- # alone already reach every value object a generated sequence
192
- # actually persisted or announced.
193
- #
194
- # @param history [Hash] a replayed history as returned by `Replay.call`
195
- # @return [Array<Hash>] one divergence Hash (`:field`, `:type`, `:original`, and
196
- # either `:rehydrated` or `:error`) per value object whose `to_json`-then-`build`
197
- # round trip disagrees with (or raises on) the original; empty when every value
198
- # object found round-trips cleanly
199
- def check_value_object_round_trip(history)
200
- bluebooks = history[:bluebooks] || {}
201
- seen = {}.compare_by_identity
202
- found = []
203
-
204
- history[:instances].each do |key, state|
205
- domain_name, aggregate_name = key.to_s.split("#", 2).first.to_s.split("::", 2)
206
- aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
207
- walk_value_objects(state, found, seen, aggregate)
208
- end
209
-
210
- history[:events].each do |event|
211
- domain_name, aggregate_name = event[:aggregate].to_s.split("::", 2)
212
- aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
213
- walk_value_objects(event[:payload], found, seen, aggregate)
214
- end
215
-
216
- found.filter_map do |value, aggregate|
217
- begin
218
- rebuilt = Runtime::Value.build(value.value_object, JSON.parse(value.to_json), aggregate)
219
- rescue StandardError => e
220
- next { field: "value_object_round_trip", type: value.type_name, original: value.to_h,
221
- error: "#{e.class}: #{e.message}" }
222
- end
223
-
224
- next if rebuilt == value
225
-
226
- { field: "value_object_round_trip", type: value.type_name, original: value.to_h,
227
- rehydrated: rebuilt.to_h }
228
- end
229
- end
230
-
231
- # Check 4 — saga cold-rehydration (angle-10). Checks 1/2 above cold-
232
- # read an aggregate's own journal through Heki; nothing in this file
233
- # ever exercised the other durable store `SagaInterpreter#checkpoint`
234
- # writes through — `Ports::Persistence::NullSagaStore`'s own header
235
- # calls Heki's `SagaStore` (`adapters/driven/heki/saga_store.rb`) the
236
- # optional saga-persistence capability, and it is real and already
237
- # shipping, just never fuzzed: `Registry#rehydrate_sagas!` — the
238
- # production "process just restarted" path — folds exactly what
239
- # `each_saga` yields back into `@saga_instances`, and until now
240
- # nothing ever proved that round trip faithful for a sequence this
241
- # practice actually generated. `BUG#6`/`#9`/`#10` all came out of
242
- # this exact interpreter, which is why this checks it specifically
243
- # rather than folding it into checks 1/2's own aggregate walk.
244
- #
245
- # `history[:saga_instances]` (`replay.rb`'s own `saga_instances`
246
- # local, built once at the very end of a replay) is the same
247
- # materialized `{pm_name => {correlation => {state:, memory:}}}`
248
- # shape `SagaInterpreter#checkpoint` itself hands a real adapter —
249
- # read from `history`, not re-derived from the (by-now-live, already
250
- # mutated by whatever `check_saga_idempotency` ran first, see that
251
- # method's own header) `runtime.registry.saga_instances`. Written
252
- # through a real `Adapters::Heki` (a throwaway tmpdir, one per
253
- # process manager so two process managers with correlations that
254
- # happen to collide as strings never share a store), read back
255
- # through a fresh instance (unmemoized `@store`/`@saga_store`, same
256
- # reason `fold!` above uses one) — forcing the identical
257
- # `JSON.generate`/`JSON.parse` boundary a real crash-then-restart
258
- # takes, not a live-object pass-through.
259
- #
260
- # One finding per (domain, process manager) — every correlation this
261
- # process manager's own `history[:saga_instances]` entry holds,
262
- # compared as a whole Hash — the same aggregate-granularity (not
263
- # per-record) `check_rehydration` already reports at.
264
- #
265
- # `completed_compensations` is deliberately out of scope — `history[
266
- # :saga_instances]` never captures it (`replay.rb`'s own comment:
267
- # only `state`/`memory` are threaded through, since a saga's
268
- # in-flight compensation ledger is a fact about a leg still running,
269
- # not the settled snapshot this history exists to describe), so
270
- # there is no ground truth to compare it against here. Written as an
271
- # empty array on the way in and never read back on the way out.
272
- #
273
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
274
- # booted runtime this replay just dispatched through
275
- # @param history [Hash] a replayed history as returned by `Replay.call`
276
- # @return [Array<Hash>] one divergence Hash (`:field`, `:domain`,
277
- # `:process_manager`, `:live`, `:rehydrated`) per process manager whose cold-read
278
- # saga instances disagree with the ones this replay persisted; empty when every
279
- # process manager with persisted instances rehydrates cleanly
280
- def check_saga_rehydration(runtime, history)
281
- saga_instances = history[:saga_instances] || {}
282
- each_domain_process_manager(runtime).filter_map do |domain_name, process_manager|
283
- persisted = saga_instances[process_manager.name]
284
- next if persisted.nil? || persisted.empty?
285
-
286
- anchor = runtime.registry.bluebook(domain_name).aggregates.first
287
- next unless anchor
288
-
289
- Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
290
- writer = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
291
- persisted.each do |correlation, saga|
292
- writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
293
- state: saga[:state], memory: saga[:memory], completed_compensations: [])
294
- end
295
-
296
- live = normalize_saga_rows(persisted)
297
- rehydrated = cold_read_saga_rows(anchor, tmp, domain_name)
298
- next if rehydrated == live
299
-
300
- { field: "saga_rehydration", domain: domain_name, process_manager: process_manager.name,
301
- live: live, rehydrated: rehydrated }
302
- end
303
- end
304
- end
305
-
306
- # Check 5 — redelivery idempotency of the checkpoint-then-load path.
307
- # `check_saga_rehydration` above proves cold-reading a checkpoint
308
- # reproduces the same data; this proves the other half of a real
309
- # crash/restart — a message an at-least-once delivery mechanism (an
310
- # outbox redrive, a queue redelivery) hands the rehydrated saga a
311
- # second time — does not silently re-advance it. There is no flag
312
- # for this in production (`SagaInterpreter#unwind`'s own comment:
313
- # "the check is the guard") — the (event, current state) lookup
314
- # `handler_for` performs is the entire mechanism, and it has never
315
- # been exercised against a state this practice loaded from cold
316
- # storage rather than one still sitting in a live process's memory.
317
- #
318
- # One (process manager, correlation) tested, using a fresh
319
- # `Runtime::SagaInterpreter` sharing `runtime`'s own `registry` and
320
- # `door: runtime` — the identical two objects the dispatcher's own
321
- # `@sagas` was built from (`dispatcher.rb`'s own `SagaInterpreter.
322
- # new(registry, door: self)`) — not a hand-rolled re-implementation
323
- # of `advance_saga`'s own state-guard. `only: process_manager` scopes
324
- # the redelivery to exactly the one procedure under test, the same
325
- # keyword the outbox relay already uses to run one consumer alone
326
- # (`Runtime::Outbox::Relay#run_consumer`).
327
- #
328
- # Which event to redeliver — `runtime.registry.saga_log`'s own last
329
- # `advanced: true` row for this (process manager, correlation) names
330
- # the event by name only; the real `Runtime::Event` object (payload,
331
- # aggregate, id, `correlation` — everything `saga_correlation`/
332
- # `dispatch_args` actually read) lives in `runtime.events`, still
333
- # live for exactly this reason (this file's own header: "runtime is
334
- # still live here"). Matched back by name plus `saga_correlation`
335
- # itself (`Runtime::SagaInterpreter::Correlation`, `private`) —
336
- # reused via `send` rather than reproduced, because reproducing its
337
- # three-tier fallback (a dotted payload field, a stamped passthrough,
338
- # a self-identifying `event.id`) here would be exactly the
339
- # hand-rolled approximation this file was told not to build. A
340
- # `:refused`-driven (compensating) transition is skipped outright —
341
- # its own `saga_log` row's `on:` is the synthetic `REFUSED` trigger
342
- # name, never a real domain event, so there is nothing to redeliver.
343
- #
344
- # Simulating "just restarted" — the live registry's own in-memory
345
- # `saga_instances[pm][correlation]` slot is overwritten, in place,
346
- # with whatever a cold Heki read of the same checkpoint answers
347
- # (exactly what `Registry#rehydrate_sagas!` does for real on every
348
- # boot), the redelivery is driven through the real interpreter, and
349
- # the slot is put back — `ensure`d — once this correlation's own
350
- # check is done. Safe only because `check`/`Replay.call` run this,
351
- # synchronously, single-threaded, as the very last thing before
352
- # `runtime` and its whole tmp directory go out of scope for good;
353
- # nothing downstream of this method ever reads the live registry
354
- # again (`check_saga_rehydration`, `check_rehydration`, `check_
355
- # idempotency`, `check_value_object_round_trip` all read `history`'s
356
- # own frozen snapshot instead, never `runtime.registry` — so calling
357
- # order relative to this method's own mutation doesn't matter).
358
- #
359
- # The assertion is about `state`/`memory`, not "did a dispatch fire"
360
- # — a leg whose own `from:`/`to:` are the same state (every existing
361
- # saga's own starts_on self-transition, `waybill.bluebook`'s own leg
362
- # 1/2) is expected to re-run on redelivery with no visible state
363
- # change at all; that is a property of the declared handler graph,
364
- # not a rehydration defect, and asserting against it here would
365
- # manufacture a false positive on every saga this corpus has. A
366
- # correlation whose current state has no declared handler at all for
367
- # the redelivered event name (the ordinary, expected case once a
368
- # saga has moved past the leg that produced its own current
369
- # checkpoint) is exactly what this proves stays put.
370
- #
371
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
372
- # booted runtime this replay just dispatched through
373
- # @param history [Hash] a replayed history as returned by `Replay.call`
374
- # @return [Array<Hash>] one divergence Hash (`:field`, `:domain`,
375
- # `:process_manager`, `:correlation`, `:on`, `:before`, `:after`) per correlation
376
- # whose redelivered event visibly changed state/memory on a rehydrated
377
- # checkpoint; empty when every redelivered correlation stayed put
378
- def check_saga_idempotency(runtime, history)
379
- saga_instances = history[:saga_instances] || {}
380
- interpreter = Runtime::SagaInterpreter.new(runtime.registry, door: runtime)
381
-
382
- each_domain_process_manager(runtime).flat_map do |domain_name, process_manager|
383
- persisted = saga_instances[process_manager.name]
384
- next [] if persisted.nil? || persisted.empty?
385
-
386
- anchor = runtime.registry.bluebook(domain_name).aggregates.first
387
- next [] unless anchor
388
-
389
- persisted.filter_map do |correlation, saga|
390
- redelivery = last_advancing_event(runtime, interpreter, process_manager, correlation)
391
- next unless redelivery
392
-
393
- check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
394
- correlation, saga, redelivery)
395
- end
396
- end
397
- end
398
-
399
- # ── Rust-side self-consistency ───────────────────────────────────
400
- #
401
- # The compiled binary's own rehydration door, already shipping —
402
- # `kernel/cli.rs`'s `run` accepts an optional top-level `"seed"` key
403
- # ("the exact 'Domain::Aggregate#id' -> state shape this run's own
404
- # 'instances' output already produces... lets a host seed prior
405
- # state back in instead of replaying `steps` from scratch every
406
- # invocation"). `Store::from_seed`/`Store::instances` are that
407
- # mechanism's own two halves — `rust/host` (docs/implemented/
408
- # decisions/0012) depends on them being true inverses for real,
409
- # today. `rust_seed_round_trip` exercises exactly that: a second,
410
- # independent invocation of the same binary, `"steps": []` (nothing
411
- # new dispatched — this is rehydration, not re-dispatch), seeded
412
- # with whatever `"instances"` a prior invocation already produced.
413
- # `differ` (unused, kept in the signature — see below) is a
414
- # `RustConformanceHelpers`-including instance (`bin/qa_sweep`'s own
415
- # `Differ`), the same argument `check_rust_rehydration`/`check_
416
- # rust_idempotency` already take from their own callers; kept here
417
- # rather than dropped from all three signatures at once so a
418
- # future differential-style reduction has a door already open,
419
- # without this module ever `require`ing `spec/support/` itself.
420
- # Neither `strip_emitted_flags!` nor any other differential-only
421
- # reduction runs here — that reduction exists so a Rust-only
422
- # bookkeeping field (`emitted_<event>`, docs/decisions/0049) never
423
- # counts against Ruby, which has no equivalent field to agree with
424
- # at all (`RustConformanceHelpers#strip_emitted_flags!`'s own
425
- # comment). This check has no Ruby side to spare — it is asking the
426
- # Rust binary whether it agrees with itself, so `emitted_*` fields
427
- # are exactly as real a fact to compare as any other. Stripping them
428
- # here would silently delete them from the returned seed-round-trip
429
- # result while leaving them present on the original `seed_instances`
430
- # a caller passes in — an asymmetric comparison that would report
431
- # every record carrying one as a rehydration divergence,
432
- # unconditionally, on every domain that has one at all. Confirmed
433
- # live against `examples/banking` (`Banking::Account`'s own
434
- # `corrects` reaction) by comparing this method's own answer against
435
- # the compiled binary's raw stdout for the identical seed call: the
436
- # raw round trip preserves `emitted_fee_applied` correctly. `spec/
437
- # self_consistency_rust_spec.rb`'s own "banking" example pins this
438
- # against a real domain going forward.
439
- #
440
- # @param binary [String] path to the compiled Rust conformance binary
441
- # @param _differ [Object] a `RustConformanceHelpers`-including instance, unused here
442
- # @param seed_instances [Hash] the `"instances"` shape to seed the fresh invocation
443
- # with, in place of replaying `steps`
444
- # @return [Hash] the fresh invocation's own `"instances"`, or a
445
- # `{"__self_consistency_error__" => String}` Hash if the binary exited nonzero or
446
- # the binary itself reported an `"error"`
447
- def rust_seed_round_trip(binary, _differ, seed_instances)
448
- stdout, status = Open3.capture2(binary, stdin_data: JSON.generate({ "steps" => [], "seed" => seed_instances }))
449
- return { "__self_consistency_error__" => "rust binary exited #{status.exitstatus}: #{stdout}" } \
450
- unless status.success?
451
-
452
- parsed = JSON.parse(stdout)
453
- return { "__self_consistency_error__" => parsed["error"] } if parsed["error"]
454
-
455
- parsed["instances"]
456
- end
457
-
458
- # Check 1, Rust side — seeding a fresh invocation with a prior
459
- # invocation's own live `"instances"` must reproduce that same
460
- # state, unchanged. `live_instances` is `rust_output["instances"]`
461
- # — the exact same value `bin/qa_sweep`'s own differential compare
462
- # already diffed against Ruby, reused here rather than re-derived.
463
- #
464
- # @param binary [String] path to the compiled Rust conformance binary
465
- # @param differ [Object] a `RustConformanceHelpers`-including instance, forwarded to
466
- # `rust_seed_round_trip` unused
467
- # @param live_instances [Hash] the prior invocation's own `"instances"` output
468
- # @return [Array<Hash>] a single divergence Hash (`:field`, `:live`, `:rehydrated`)
469
- # if seeding with `live_instances` fails to reproduce it; empty otherwise
470
- def check_rust_rehydration(binary, differ, live_instances)
471
- rehydrated = rust_seed_round_trip(binary, differ, live_instances)
472
- return [] if rehydrated == live_instances
473
-
474
- [{ field: "rust_rehydration", live: live_instances, rehydrated: rehydrated }]
475
- end
476
-
477
- # Check 2, Rust side — seeding with what a first seed round trip
478
- # already produced, a second time, must not drift any further. The
479
- # same "replay it again, byte for byte" claim `check_idempotency`
480
- # proves for Ruby, aimed at the one rehydration door this compiled
481
- # binary actually has.
482
- #
483
- # @param binary [String] path to the compiled Rust conformance binary
484
- # @param differ [Object] a `RustConformanceHelpers`-including instance, forwarded to
485
- # `rust_seed_round_trip` unused
486
- # @param live_instances [Hash] the prior invocation's own `"instances"` output to
487
- # seed the first round trip with
488
- # @return [Array<Hash>] a single divergence Hash (`:field`, `:once`, `:twice`) if a
489
- # second seed round trip disagrees with the first; empty otherwise
490
- def check_rust_idempotency(binary, differ, live_instances)
491
- once = rust_seed_round_trip(binary, differ, live_instances)
492
- twice = rust_seed_round_trip(binary, differ, once)
493
- return [] if once == twice
494
-
495
- [{ field: "rust_idempotency", once: once, twice: twice }]
496
- end
497
-
498
- # ── shared plumbing ─────────────────────────────────────────────
499
-
500
- # Every [domain, aggregate] pair this sequence actually wrote to —
501
- # an aggregate with an empty `#entries` never had anything dispatch
502
- # against it this run, so there is nothing to rehydrate and no
503
- # finding a "clean, nothing touched" report would mean anything
504
- # for. Mirrors `Replay#snapshot_instances`' own
505
- # `bluebooks.each { aggregates.each { repository(...) } }` walk.
506
- #
507
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
508
- # booted runtime this replay just dispatched through
509
- # @return [Array<Array(String, Bluebook::Aggregate, Ports::Persistence::AppendOnly,
510
- # Array<Ports::Persistence::Entry>)>] one `[domain_name, aggregate, repository,
511
- # entries]` tuple per aggregate with at least one journal entry
512
- def each_touched_repository(runtime)
513
- found = []
514
- runtime.registry.bluebooks.each do |domain_name, bluebook|
515
- bluebook.aggregates.each do |aggregate|
516
- repository = runtime.registry.repository(domain_name, aggregate)
517
- entries = repository.entries
518
- next if entries.empty?
519
-
520
- found << [domain_name, aggregate, repository, entries]
521
- end
522
- end
523
- found
524
- end
525
-
526
- # Materializes a repository's own stored records, keyed by id, for comparing
527
- # against a cold read of the same aggregate.
528
- #
529
- # @param repository [Ports::Persistence::AppendOnly] the repository to read
530
- # @return [Hash{String => Object}] every stored record's id (stringified) mapped to
531
- # its materialized state
532
- def snapshot(repository)
533
- repository.all.to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
534
- end
535
-
536
- # Every [domain, process manager] pair any loaded bluebook declares —
537
- # regardless of whether this replay's own `history[:saga_instances]`
538
- # ever touched it (mirrors `each_touched_repository`'s own walk one
539
- # level up; the "did anything actually persist" filter lives in each
540
- # check's own caller, same as that method's `entries.empty?` guard).
541
- # An empty return here is the "domain declares no process manager"
542
- # skip `check_saga_rehydration`/`check_saga_idempotency` both need —
543
- # `filter_map`/`flat_map` over an empty Array already answers `[]`,
544
- # identical to "ran and found nothing," which is deliberate: neither
545
- # check has a positive "passed" artifact to report either way (see
546
- # this file's own header on why silence is never a claimed pass).
547
- #
548
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
549
- # booted runtime this replay just dispatched through
550
- # @return [Array<Array(String, Bluebook::ProcessManager)>] one `[domain_name,
551
- # process_manager]` tuple per process manager any loaded bluebook declares
552
- def each_domain_process_manager(runtime)
553
- found = []
554
- runtime.registry.bluebooks.each do |domain_name, bluebook|
555
- bluebook.process_managers.each { |pm| found << [domain_name, pm] }
556
- end
557
- found
558
- end
559
-
560
- # The live-side ground truth, key-shape-normalized (see `deep_
561
- # stringify_keys`'s own comment) so it compares fairly against a
562
- # real Heki round trip's own shallow-symbolize convention.
563
- #
564
- # @param persisted [Hash{String, Symbol => Hash}] `history[:saga_instances]`'s own
565
- # per-process-manager entry, keyed by correlation, each holding `:state`/`:memory`
566
- # @return [Hash{String => Hash}] the same rows, keyed by stringified correlation,
567
- # with `:memory` recursively string-keyed
568
- def normalize_saga_rows(persisted)
569
- persisted.each_with_object({}) do |(correlation, saga), rows|
570
- rows[correlation.to_s] = { state: saga[:state], memory: deep_stringify_keys(saga[:memory]) }
571
- end
572
- end
573
-
574
- # A fresh `Adapters::Heki` at the same `tmp`/`domain` — unmemoized
575
- # `@store`/`@saga_store`, so `#each_saga` is forced back through
576
- # `read_snapshot`/`replay_journal`, real bytes off real disk, not
577
- # whatever the writer that just wrote them still holds in its own
578
- # process memory (the same reason `fold!`, below, opens a second
579
- # `Adapters::Heki` instance rather than reading its own writer back).
580
- #
581
- # @param anchor [Bluebook::Aggregate] any aggregate of the owning domain, used only
582
- # to open a `Heki` store at the right domain/root
583
- # @param tmp [String] the throwaway directory the saga store was written into
584
- # @param domain_name [String] the owning domain's name, threaded into the store's
585
- # own `settings`
586
- # @return [Hash{String => Hash}] every persisted correlation's `:state`/`:memory`,
587
- # with `:memory` recursively string-keyed
588
- def cold_read_saga_rows(anchor, tmp, domain_name)
589
- reader = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
590
- reader.each_saga.with_object({}) do |(_pm, correlation, state, memory, _completed), rows|
591
- rows[correlation] = { state: state, memory: deep_stringify_keys(memory) }
592
- end
593
- end
594
-
595
- # `SagaStore#each_saga` only ever symbolizes `memory`'s own top-level
596
- # keys (`heki/saga_store.rb`'s own `each_saga`, one level deep) —
597
- # `Registry::SagaPersistence#warn_stalled_saga` already documents
598
- # this exact asymmetry for the one reserved key production code
599
- # cares about (`SAGA_PENDING_DISPATCH_KEY`). A nested composite
600
- # memory field (any saga whose starting event carries a value
601
- # object, which is most of them — `waybill.bluebook`'s own
602
- # `ConsignmentRequested` alone has three) comes back with string
603
- # keys at every level below the top, while `history[:saga_instances]`
604
- # 's own `Runtime::Value.materialize` call produces symbol keys
605
- # throughout. That asymmetry is Heki's own documented, accepted
606
- # storage convention — an "opaque, adapter-agnostic JSON blob"
607
- # (`SagaInterpreter#checkpoint`'s own comment), never a typed
608
- # rebuild the way an aggregate's own composite fields get on cold
609
- # read (this file's own header: there is no VO schema to rebuild
610
- # against for a saga's memory blob at all) — not a rehydration
611
- # defect this check exists to find. Recursively re-stringifying
612
- # both sides before comparing is what tells that known, accepted
613
- # shape difference apart from an actual data-loss bug (a dropped
614
- # key, a changed value, a missing field) — exactly the kind (b)'s
615
- # own seeded fixture in `spec/fuzzing/self_consistency_saga_spec.rb`
616
- # proves this still catches.
617
- #
618
- # @param value [Object] any value; only Hash and Array are recursed into
619
- # @return [Object] `value` with every Hash key (at every nesting level) replaced by
620
- # its own `to_s`; anything else is returned unchanged
621
- def deep_stringify_keys(value)
622
- case value
623
- when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
624
- when Array then value.map { |item| deep_stringify_keys(item) }
625
- else value
626
- end
627
- end
628
-
629
- # The real, already-announced event this correlation's current
630
- # checkpoint came from — walked back out of `runtime.registry.
631
- # saga_log`'s own `advanced: true` rows (newest first), skipping the
632
- # synthetic `REFUSED` trigger (`Runtime::SagaInterpreter::REFUSED`
633
- # — a compensating transition's own log entry names that, never a
634
- # real domain event; there is nothing in `runtime.events` to
635
- # redeliver for it). `nil` when no real advancing event exists at
636
- # all (a correlation only ever `begin_saga`'d, never advanced) —
637
- # `check_saga_idempotency`'s own caller skips a `nil` outright,
638
- # exactly like `each_touched_repository`'s own "nothing to check"
639
- # skip one level up.
640
- #
641
- # `interpreter.send(:saga_correlation, ...)` — `Correlation` is
642
- # `private`, and reproducing its own three-tier fallback (a dotted
643
- # payload field, a stamped passthrough, a self-identifying
644
- # `event.id`) here rather than reusing it would be exactly the
645
- # "hand-rolled approximation" this check exists to avoid; `send` on
646
- # an interpreter sharing this same `runtime`'s own registry is the
647
- # real thing, not a copy of it.
648
- #
649
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
650
- # booted runtime this replay just dispatched through
651
- # @param interpreter [Runtime::SagaInterpreter] the interpreter whose (private)
652
- # `saga_correlation` re-derives this conversation's own correlation
653
- # @param process_manager [Bluebook::ProcessManager] the process manager whose saga
654
- # log is walked
655
- # @param correlation [Object] the correlation identifier to match, as
656
- # `saga_correlation` produces it — typically a String
657
- # @return [Runtime::Event, nil] the real domain event this correlation's current
658
- # checkpoint last advanced on, or nil if it was only ever `begin_saga`'d
659
- def last_advancing_event(runtime, interpreter, process_manager, correlation)
660
- entry = runtime.registry.saga_log.reverse_each.find do |row|
661
- row[:process_manager] == process_manager.name && row[:instance] == correlation &&
662
- row[:advanced] && row[:on] != Runtime::SagaInterpreter::REFUSED
663
- end
664
- return nil unless entry
665
-
666
- runtime.events.reverse_each.find do |event|
667
- event.name == entry[:on] && interpreter.send(:saga_correlation, process_manager, event) == correlation
668
- end
669
- end
670
-
671
- # One (process manager, correlation)'s own redelivery check — pulled
672
- # out of `check_saga_idempotency` itself so that method's own
673
- # `flat_map`/`filter_map` walk stays readable; every local this
674
- # shares with its caller (`interpreter`, `anchor`) is passed in
675
- # rather than re-derived. The `saga_log` mark/restore pair (BUG#39
676
- # fix) belongs right where it guards `interpreter.advance`, not in
677
- # a helper a reader would have to jump to just to see what is and
678
- # isn't being restored around that one call.
679
- #
680
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
681
- # booted runtime this replay just dispatched through
682
- # @param interpreter [Runtime::SagaInterpreter] the interpreter to redeliver through
683
- # @param domain_name [String] the owning domain's name
684
- # @param process_manager [Bluebook::ProcessManager] the process manager under test
685
- # @param anchor [Bluebook::Aggregate] any aggregate of the owning domain, used only
686
- # to open a `Heki` store at the right domain/root
687
- # @param correlation [Object] the conversation's own correlation identifier
688
- # @param saga [Hash] this correlation's persisted `:state`/`:memory`
689
- # @param redelivery [Runtime::Event] the real domain event to redeliver
690
- # @return [Hash, nil] a divergence Hash (`:field`, `:domain`, `:process_manager`,
691
- # `:correlation`, `:on`, `:before`, `:after`) if the redelivery visibly changed
692
- # state/memory; nil if it left the checkpoint unchanged, or if the cold-read
693
- # checkpoint could not be found at all
694
- def check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
695
- correlation, saga, redelivery)
696
- # rubocop:disable-next Metrics/BlockLength
697
- Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
698
- writer = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
699
- writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
700
- state: saga[:state], memory: saga[:memory], completed_compensations: [])
701
-
702
- rehydrated = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
703
- .each_saga.find { |_pm, corr, *| corr == correlation.to_s }
704
- next unless rehydrated
705
-
706
- _pm, _corr, state, memory, compensations = rehydrated
707
- before = { state: state, memory: deep_stringify_keys(memory) }
708
-
709
- saga_instances = runtime.registry.saga_instances[process_manager.name]
710
- original = saga_instances[correlation]
711
-
712
- # `saga_log` is `runtime.sagas`/`history[:sagas]` itself, by
713
- # reference (`Dispatcher#sagas` — `@registry.saga_log`).
714
- # `interpreter.advance` below is a real `advance_saga` dispatch,
715
- # which appends its own row to this same array unconditionally —
716
- # a true fact about this probe, but an unrestored append leaks
717
- # into the primary trace (BUG#39). Marked here, sliced back off
718
- # in `ensure`, same restore-what-I-mutated idiom as
719
- # `saga_instances[correlation]` below.
720
- saga_log = runtime.registry.saga_log
721
- saga_log_mark = saga_log.size
722
- begin
723
- saga_instances[correlation] = { state: state, memory: memory, completed_compensations: compensations || [] }
724
- interpreter.advance(redelivery, domain_name, only: process_manager)
725
-
726
- after = saga_instances[correlation]
727
- after_shape = after && { state: after[:state], memory: deep_stringify_keys(after[:memory]) }
728
- next if after_shape == before
729
-
730
- { field: "saga_redelivery_idempotency", domain: domain_name, process_manager: process_manager.name,
731
- correlation: correlation, on: redelivery.name, before: before, after: after_shape }
732
- ensure
733
- saga_log.slice!(saga_log_mark..) if saga_log.size > saga_log_mark
734
-
735
- if original
736
- saga_instances[correlation] = original
737
- else
738
- saga_instances.delete(correlation)
739
- end
740
- end
741
- end
742
- end
743
-
744
- # One fold of `entries` into `writer` (a real, already-open `Heki`
745
- # adapter at `tmp`), then a cold read back through a brand new `Heki`
746
- # instance at the same path — `@store` on a fresh instance starts
747
- # unmemoized, so `#all` below is forced through `#read` →
748
- # `#read_snapshot`/`#replay_journal`, real disk bytes, not whatever
749
- # `writer` still holds cached in its own process memory. Called
750
- # twice in a row against the same `writer` (see `check`/
751
- # `check_idempotency` above) is exactly "replay the same journal a
752
- # second time" — `writer` already holds everything the first fold
753
- # wrote, so a second fold re-applies the identical operations on
754
- # top, and the two cold reads either agree (idempotent) or don't.
755
- #
756
- # @param writer [Adapters::Heki] the open store to append `entries` into
757
- # @param tmp [String] the throwaway directory `writer` was opened against
758
- # @param aggregate [Bluebook::Aggregate] the aggregate `entries` belongs to
759
- # @param entries [Array<Ports::Persistence::Entry>] the journal entries to fold in
760
- # @return [Hash{String => Object}] every stored record's id (stringified) mapped to
761
- # its materialized state, read back cold
762
- def fold!(writer, tmp, aggregate, entries)
763
- entries.each do |entry|
764
- writer.append(entry)
765
- writer.project(entry)
766
- end
767
-
768
- guarded_heki(aggregate: aggregate, root: tmp).all
769
- .to_h do |record|
770
- [
771
- record.id.to_s, Runtime::Value.materialize(record.state)
772
- ]
773
- end
774
- end
775
-
776
- # Recurses through a `Value`'s own fields via `#[]`, not `#to_h` —
777
- # `#to_h` already materializes every field (`Value.materialize`),
778
- # which would hide a nested `Value` from this walk before it ever
779
- # got here. In practice a value object's own composite fields
780
- # (`Coercion#normalize_composite_fields`) are validated but stored
781
- # as plain, already-materialized Hashes, not re-wrapped `Value`
782
- # instances — confirmed live, not assumed — so this recursion finds
783
- # nothing further past the field it started from for today's
784
- # coercion pipeline specifically. Kept anyway, not dead code: the
785
- # generic `Hash`/`Array` branches below reach the exact same nested
786
- # data through `node[attribute.name]` regardless, and a future
787
- # change that does start wrapping composite fields as real `Value`
788
- # instances would be walked correctly here with no change needed.
789
- # `seen` is a `compare_by_identity` Hash: the same instance can legitimately
790
- # appear more than once (an aggregate's live state and an event
791
- # payload both reference the exact same frozen object), and
792
- # checking it twice would just waste time, never change the
793
- # answer — identity, not `Value#==`, is the right notion of
794
- # "already found" here (two different value objects that happen to
795
- # hold equal fields are still two separate round trips to prove).
796
- #
797
- # @param node [Object] the value to walk; a `Runtime::Value` is recorded and
798
- # recursed into by field, a Hash or Array is recursed into by value/element,
799
- # anything else is ignored
800
- # @param found [Array<Array(Runtime::Value, Bluebook::Aggregate, nil)>] accumulator
801
- # this method appends `[value, aggregate]` pairs to, in place
802
- # @param seen [Hash] a `compare_by_identity` Hash used as a found-by-identity set,
803
- # mutated in place
804
- # @param aggregate [Bluebook::Aggregate, nil] the aggregate `node` belongs to, carried
805
- # along unchanged for every value found beneath it
806
- # @return [void]
807
- def walk_value_objects(node, found, seen, aggregate)
808
- case node
809
- when Runtime::Value
810
- return if seen[node]
811
-
812
- seen[node] = true
813
- found << [node, aggregate]
814
- node.value_object.attributes.each do |attribute|
815
- walk_value_objects(node[attribute.name], found, seen, aggregate)
816
- end
817
- when Hash
818
- node.each_value { |value| walk_value_objects(value, found, seen, aggregate) }
819
- when Array
820
- node.each { |value| walk_value_objects(value, found, seen, aggregate) }
821
- end
822
- end
823
- end
824
- end
825
- end