hecks 1.3.0 → 1.5.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 (373) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +72 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +83 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
  7. data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
  9. data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
  10. data/lib/hecks/adapters/driven/heki.rb +106 -9
  11. data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
  12. data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
  13. data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
  15. data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
  16. data/lib/hecks/adapters/driven/lambda.rb +82 -35
  17. data/lib/hecks/adapters/driven/local_storage.rb +83 -10
  18. data/lib/hecks/adapters/driven/memory.rb +205 -9
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  24. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  25. data/lib/hecks/adapters/driven/prism.rb +50 -11
  26. data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  31. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  32. data/lib/hecks/adapters/driven/system_clock.rb +3 -0
  33. data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
  34. data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
  35. data/lib/hecks/adapters/driven.rb +6 -4
  36. data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
  37. data/lib/hecks/behaviors/dsl.rb +60 -2
  38. data/lib/hecks/behaviors/expectations.rb +190 -29
  39. data/lib/hecks/behaviors/ir.rb +12 -1
  40. data/lib/hecks/behaviors/rspec.rb +9 -1
  41. data/lib/hecks/behaviors/runner.rb +21 -2
  42. data/lib/hecks/behaviors.rb +9 -1
  43. data/lib/hecks/bluebook/aggregate.rb +43 -13
  44. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
  45. data/lib/hecks/bluebook/assembly/build.rb +26 -5
  46. data/lib/hecks/bluebook/assembly/contract.rb +98 -23
  47. data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
  48. data/lib/hecks/bluebook/assembly/marks.rb +159 -30
  49. data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
  50. data/lib/hecks/bluebook/assembly.rb +32 -14
  51. data/lib/hecks/bluebook/attribute.rb +26 -12
  52. data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
  53. data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
  54. data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
  55. data/lib/hecks/bluebook/behaviour/command.rb +55 -25
  56. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
  57. data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
  58. data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
  59. data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
  60. data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
  61. data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
  62. data/lib/hecks/bluebook/behaviour/query.rb +6 -1
  63. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
  64. data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
  65. data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
  66. data/lib/hecks/bluebook/capabilities.rb +27 -0
  67. data/lib/hecks/bluebook/chapter.rb +51 -9
  68. data/lib/hecks/bluebook/command.rb +62 -17
  69. data/lib/hecks/bluebook/domain_port.rb +34 -9
  70. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  71. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  72. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
  73. data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
  74. data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
  75. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
  76. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
  77. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  78. data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
  79. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  80. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  81. data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
  82. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  83. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
  84. data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
  85. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  86. data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
  87. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  88. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  89. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
  90. data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
  91. data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
  92. data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
  93. data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
  94. data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
  95. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  96. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  97. data/lib/hecks/bluebook/entity.rb +40 -11
  98. data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
  99. data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
  100. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
  101. data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
  102. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
  103. data/lib/hecks/bluebook/expression/resolver.rb +369 -128
  104. data/lib/hecks/bluebook/hexagon.rb +35 -1
  105. data/lib/hecks/bluebook/lifecycle.rb +12 -1
  106. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
  107. data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
  108. data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
  109. data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
  110. data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
  111. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
  112. data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
  113. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
  114. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
  115. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
  116. data/lib/hecks/bluebook/meta_validator.rb +235 -139
  117. data/lib/hecks/bluebook/model_check.rb +434 -104
  118. data/lib/hecks/bluebook/pattern_subset.rb +32 -10
  119. data/lib/hecks/bluebook/policy.rb +15 -13
  120. data/lib/hecks/bluebook/process_manager.rb +27 -14
  121. data/lib/hecks/bluebook/project_discovery.rb +5 -0
  122. data/lib/hecks/bluebook/project_loader.rb +40 -0
  123. data/lib/hecks/bluebook/project_register.rb +50 -6
  124. data/lib/hecks/bluebook/query.rb +31 -4
  125. data/lib/hecks/bluebook/read_model.rb +35 -15
  126. data/lib/hecks/bluebook/reference.rb +26 -13
  127. data/lib/hecks/bluebook/smoke_test.rb +46 -23
  128. data/lib/hecks/bluebook/synthesizer.rb +46 -12
  129. data/lib/hecks/bluebook/translation.rb +34 -5
  130. data/lib/hecks/bluebook/value_object.rb +29 -11
  131. data/lib/hecks/bluebook.rb +5 -6
  132. data/lib/hecks/codemod.rb +138 -50
  133. data/lib/hecks/construct.rb +21 -7
  134. data/lib/hecks/corpus.rb +438 -0
  135. data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
  136. data/lib/hecks/doc/reference.rb +200 -31
  137. data/lib/hecks/embryonaut_bluebook.rb +38 -15
  138. data/lib/hecks/facade/cli_door.rb +69 -10
  139. data/lib/hecks/facade/cli_runner.rb +105 -24
  140. data/lib/hecks/facade/command_request.rb +23 -0
  141. data/lib/hecks/facade/handle.rb +155 -35
  142. data/lib/hecks/facade/json_door.rb +106 -25
  143. data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
  144. data/lib/hecks/facade/surface/chapter.rb +26 -17
  145. data/lib/hecks/facade/surface.rb +16 -3
  146. data/lib/hecks/facade.rb +15 -4
  147. data/lib/hecks/forms/app.rb +46 -30
  148. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  149. data/lib/hecks/forms/field_renderer.rb +142 -6
  150. data/lib/hecks/forms/field_shape.rb +183 -19
  151. data/lib/hecks/forms/html.rb +51 -7
  152. data/lib/hecks/forms/index_renderer.rb +14 -2
  153. data/lib/hecks/forms/page.rb +14 -0
  154. data/lib/hecks/forms/params.rb +120 -23
  155. data/lib/hecks/forms/port_argument.rb +14 -2
  156. data/lib/hecks/forms/query_form_renderer.rb +65 -2
  157. data/lib/hecks/forms/record_renderer.rb +60 -2
  158. data/lib/hecks/forms/record_table.rb +28 -1
  159. data/lib/hecks/forms/reference_options.rb +24 -0
  160. data/lib/hecks/forms/value_object_shape.rb +13 -3
  161. data/lib/hecks/forms.rb +24 -4
  162. data/lib/hecks/fqn.rb +59 -1
  163. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  164. data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
  165. data/lib/hecks/framework/oidc.json +15 -0
  166. data/lib/hecks/framework.rb +79 -25
  167. data/lib/hecks/freezer.rb +27 -11
  168. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
  169. data/lib/hecks/fuzzing/combination_miner.rb +178 -0
  170. data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
  171. data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
  172. data/lib/hecks/fuzzing/differential.rb +192 -0
  173. data/lib/hecks/fuzzing/domain_generator.rb +871 -0
  174. data/lib/hecks/fuzzing/era_boundary.rb +68 -19
  175. data/lib/hecks/fuzzing/form_census.rb +121 -20
  176. data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
  177. data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
  178. data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
  179. data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
  180. data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
  181. data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
  182. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
  183. data/lib/hecks/fuzzing/properties/guards.rb +86 -42
  184. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
  185. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
  186. data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
  187. data/lib/hecks/fuzzing/properties/querying.rb +82 -28
  188. data/lib/hecks/fuzzing/properties.rb +84 -51
  189. data/lib/hecks/fuzzing/qa_settings.rb +164 -0
  190. data/lib/hecks/fuzzing/replay.rb +241 -133
  191. data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
  192. data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
  193. data/lib/hecks/fuzzing/self_consistency.rb +315 -137
  194. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
  195. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  196. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  197. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  198. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
  199. data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
  200. data/lib/hecks/fuzzing/shrinker.rb +309 -0
  201. data/lib/hecks/fuzzing/structural_skips.rb +37 -130
  202. data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
  203. data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
  204. data/lib/hecks/fuzzing/value_generator.rb +110 -22
  205. data/lib/hecks/fuzzing.rb +5 -0
  206. data/lib/hecks/grammar/evolve.rb +188 -12
  207. data/lib/hecks/grammar.rb +53 -7
  208. data/lib/hecks/ir.rb +51 -20
  209. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  210. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  211. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  212. data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
  213. data/lib/hecks/language/oidc.json +5 -0
  214. data/lib/hecks/literal.rb +41 -9
  215. data/lib/hecks/naming.rb +112 -31
  216. data/lib/hecks/ports/access_control.rb +53 -2
  217. data/lib/hecks/ports/agent/answers.rb +83 -6
  218. data/lib/hecks/ports/agent.rb +119 -35
  219. data/lib/hecks/ports/authentication.rb +44 -4
  220. data/lib/hecks/ports/authorization.rb +50 -11
  221. data/lib/hecks/ports/clock.rb +42 -23
  222. data/lib/hecks/ports/extraction.rb +16 -0
  223. data/lib/hecks/ports/identity_assignment.rb +23 -2
  224. data/lib/hecks/ports/identity_generation.rb +17 -3
  225. data/lib/hecks/ports/identity_resolution.rb +17 -1
  226. data/lib/hecks/ports/key_vault.port +6 -0
  227. data/lib/hecks/ports/key_vault.rb +58 -0
  228. data/lib/hecks/ports/loading.rb +4 -0
  229. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  230. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  231. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  232. data/lib/hecks/ports/persistence/execution.rb +4 -0
  233. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  234. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  235. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  236. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  237. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  238. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  239. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  250. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  251. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  252. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
  253. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  254. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  255. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  256. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  257. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  258. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
  259. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  260. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  261. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  262. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
  263. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
  264. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
  265. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
  266. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  267. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  268. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  269. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  270. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  271. data/lib/hecks/ports/persistence.rb +36 -1
  272. data/lib/hecks/ports/projection.rb +61 -7
  273. data/lib/hecks/ports/query/in_memory.rb +41 -8
  274. data/lib/hecks/ports/query/ordering.rb +21 -6
  275. data/lib/hecks/ports/query.rb +35 -0
  276. data/lib/hecks/ports.rb +1 -0
  277. data/lib/hecks/projections/bootstrap_table.rb +147 -0
  278. data/lib/hecks/projections/diagrams.rb +315 -79
  279. data/lib/hecks/projections/glossary/html.rb +91 -3
  280. data/lib/hecks/projections/glossary/markdown.rb +50 -3
  281. data/lib/hecks/projections/glossary/mermaid.rb +49 -1
  282. data/lib/hecks/projections/glossary/sections.rb +1 -1
  283. data/lib/hecks/projections/glossary/sentences.rb +110 -5
  284. data/lib/hecks/projections/glossary.rb +173 -31
  285. data/lib/hecks/projections/ir.rb +1 -1
  286. data/lib/hecks/projections/model/deviations.rb +62 -17
  287. data/lib/hecks/projections/model.rb +75 -21
  288. data/lib/hecks/projections/oidc.rb +45 -8
  289. data/lib/hecks/projections/parser_table.rb +53 -12
  290. data/lib/hecks/projections/reference.rb +15 -3
  291. data/lib/hecks/projections/rust_vocabulary.rb +646 -0
  292. data/lib/hecks/projections/shape.rb +10 -2
  293. data/lib/hecks/projections/statements.rb +69 -22
  294. data/lib/hecks/projections/vocabulary.rb +26 -9
  295. data/lib/hecks/projections.rb +5 -3
  296. data/lib/hecks/projector/cli_projector.rb +245 -37
  297. data/lib/hecks/projector/docs_projector.rb +154 -28
  298. data/lib/hecks/projector/exporter.rb +104 -29
  299. data/lib/hecks/projector/ir_projector.rb +7 -1
  300. data/lib/hecks/projector/narrate_projector.rb +144 -23
  301. data/lib/hecks/projector/target.rb +42 -18
  302. data/lib/hecks/projector.rb +86 -18
  303. data/lib/hecks/query_ir.rb +94 -47
  304. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  305. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  306. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  307. data/lib/hecks/query_specification/common/null_policy.rb +61 -15
  308. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  309. data/lib/hecks/query_specification/common/options.rb +25 -0
  310. data/lib/hecks/query_specification/field_path.rb +69 -15
  311. data/lib/hecks/query_specification/hop_path.rb +57 -20
  312. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  313. data/lib/hecks/rendering.rb +9 -3
  314. data/lib/hecks/router/namespace_installer.rb +16 -3
  315. data/lib/hecks/router.rb +56 -1
  316. data/lib/hecks/runtime/aggregate_lock.rb +20 -11
  317. data/lib/hecks/runtime/boot_gates.rb +21 -3
  318. data/lib/hecks/runtime/caller.rb +40 -8
  319. data/lib/hecks/runtime/capability_graph.rb +13 -2
  320. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
  321. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
  322. data/lib/hecks/runtime/command_interpreter.rb +195 -113
  323. data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
  324. data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
  325. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  326. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  327. data/lib/hecks/runtime/command_rules/references.rb +145 -55
  328. data/lib/hecks/runtime/command_rules.rb +3 -0
  329. data/lib/hecks/runtime/dependency_planning.rb +56 -11
  330. data/lib/hecks/runtime/dispatcher.rb +263 -92
  331. data/lib/hecks/runtime/entity_element.rb +304 -67
  332. data/lib/hecks/runtime/entity_interpreter.rb +149 -88
  333. data/lib/hecks/runtime/errors.rb +37 -23
  334. data/lib/hecks/runtime/event.rb +15 -10
  335. data/lib/hecks/runtime/identity.rb +91 -23
  336. data/lib/hecks/runtime/instance.rb +103 -18
  337. data/lib/hecks/runtime/interpreting.rb +24 -16
  338. data/lib/hecks/runtime/invocation.rb +358 -0
  339. data/lib/hecks/runtime/loader.rb +105 -19
  340. data/lib/hecks/runtime/outbox.rb +164 -26
  341. data/lib/hecks/runtime/policy_interpreter.rb +73 -60
  342. data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
  343. data/lib/hecks/runtime/query_interpreter.rb +93 -74
  344. data/lib/hecks/runtime/reaction_invocation.rb +73 -28
  345. data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
  346. data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
  347. data/lib/hecks/runtime/reference_hop.rb +48 -6
  348. data/lib/hecks/runtime/refusal_wording.rb +142 -115
  349. data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
  350. data/lib/hecks/runtime/registry/verification.rb +153 -28
  351. data/lib/hecks/runtime/registry.rb +202 -32
  352. data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
  353. data/lib/hecks/runtime/routing.rb +36 -154
  354. data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
  355. data/lib/hecks/runtime/saga_interpreter.rb +90 -76
  356. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  357. data/lib/hecks/runtime/tenant_check.rb +33 -13
  358. data/lib/hecks/runtime/tenant_scope.rb +23 -5
  359. data/lib/hecks/runtime/value/admission.rb +75 -30
  360. data/lib/hecks/runtime/value/coercion.rb +555 -142
  361. data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
  362. data/lib/hecks/runtime/value.rb +71 -21
  363. data/lib/hecks/runtime.rb +39 -7
  364. data/lib/hecks/storehouse.rb +368 -72
  365. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
  366. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
  367. data/lib/hecks/version.rb +3 -3
  368. data/lib/hecks/vocabulary.rb +205 -4
  369. data/lib/hecks.rb +91 -11
  370. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
  371. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
  372. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
  373. metadata +28 -2
@@ -6,61 +6,64 @@ require_relative "../runtime/saga_interpreter"
6
6
 
7
7
  module Hecks
8
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
9
+ # **A second comparison axis** — every other check this practice runs is
10
+ # differential: Ruby vs the compiled Rust conformance binary, on the
11
11
  # same generated sequence (`bin/qa_sweep`'s own `diff_ruby_vs_rust`,
12
12
  # `Properties.check`). Differential comparison structurally cannot
13
- # catch a bug where both engines are wrong the SAME way, or where a
13
+ # catch a bug where both engines are wrong the same way, or where a
14
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?
15
+ # compare it against. This module asks a different question of one
16
+ # engine at a time: does it agree with itself?
17
17
  #
18
- # THREE CHECKS, run on the SAME already-generated sequence and its
19
- # resulting live state — no second fuzzing pass, no re-dispatch
20
- # through the command layer:
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:
21
23
  #
22
24
  # 1. `check_rehydration` — does reloading an aggregate from its
23
25
  # own durable journal reproduce exactly the state a live dispatch
24
26
  # already produced?
25
- # 2. `check_idempotency` — does replaying that SAME journal a
27
+ # 2. `check_idempotency` — does replaying that same journal a
26
28
  # second time change anything? (a variant of #1, but a different
27
29
  # failure mode: no leaked state between applications, not just
28
30
  # "cold load works once.")
29
31
  # 3. `check_value_object_round_trip` — does every value object the
30
32
  # sequence actually built survive `to_json` then rebuilt back?
31
33
  #
32
- # THE REHYDRATION PATH, FOUND, NOT GUESSED. hecks is not event-sourced
33
- # at the aggregate level — there is no `AccountOpened`-shaped log a
34
- # `CommandInterpreter` folds to rebuild state. What there IS, real and
35
- # already shipping, is `Ports::Persistence::AppendOnly` (lib/hecks/
36
- # ports/persistence/append_only.rb): every adapter accepts the same
37
- # `Entry` stream (`operation`, `id`, the FULL state after that
38
- # command — not a delta) and answers `#entries`; `#recover!` — "an
39
- # append is durable before a projection is attempted; replaying the
40
- # log restores a snapshot/table after a crash in that small window" —
41
- # is called on EVERY repository this runtime ever builds
42
- # (`RepositoryFactory.build`'s own `recover: true` default). That IS
43
- # the production cold-rehydration path. Reusing `#recover!` directly
44
- # against the LIVE adapter would prove nothing, though: `Fuzzing::
45
- # Replay` runs against `Adapters::Memory` (`IsolatedBoot`'s own
46
- # default), and Memory's own `Entry#state` is a SHALLOW `instance.
47
- # state.dup` — the exact same `Runtime::Value` objects a command
48
- # produced ride along unchanged, so folding them straight back through
49
- # Memory's own `#project` is a tautology that can never fail (`Value#
50
- # for_attribute`'s own `value.is_a?(self) && value.type_name == ...`
51
- # branch passes an already-typed value straight through, no
52
- # rebuilding at all).
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).
53
56
  #
54
- # So `cold_read`, below, feeds the SAME entries through `Adapters::
55
- # Heki` instead — a REAL, already-shipped, disk-backed adapter
57
+ # So `fold!`, below, feeds the same entries through `Adapters::Heki`
58
+ # instead — a real, already-shipped, disk-backed adapter
56
59
  # (examples/banking's own `persisted_by("Heki")`), in a throwaway
57
60
  # directory. Writing forces every value through `JSON.generate`
58
61
  # (Heki's own journal line, its own compressed snapshot); reading
59
- # back through a FRESH `Heki` instance (unmemoized `@store`) forces
62
+ # back through a fresh `Heki` instance (unmemoized `@store`) forces
60
63
  # `read_snapshot`/`replay_journal` — real `JSON.parse`, real
61
64
  # `Zlib::Inflate`, real bytes off a real filesystem — which is what
62
65
  # makes `Instance.hydrate_with_defaults` → `Value.hydrate` →
63
- # `Value.for_attribute` actually REBUILD every value object from raw
66
+ # `Value.for_attribute` actually rebuild every value object from raw
64
67
  # data via `Value.build`, the same coercion/validation path a real
65
68
  # restart takes, rather than pass the live object through unchanged.
66
69
  # This is the exact mechanism `AppendOnly#recover!` names in its own
@@ -70,15 +73,36 @@ module Hecks
70
73
  module SelfConsistency
71
74
  module_function
72
75
 
73
- # THE WHOLE PASS — called once, with the runtime STILL LIVE (inside
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
74
90
  # `Replay.call`'s own `IsolatedBoot.call` block, before the tmp
75
91
  # directory and its adapters go out of scope) and the `history`
76
92
  # `Replay.call` is about to return. Nothing here boots a second
77
93
  # runtime or dispatches a single command; every check below reads
78
- # data this ONE replay already produced. Just the three checks
94
+ # data this one replay already produced. Just the three checks
79
95
  # below, run and collected — kept as three independently callable
80
96
  # methods (not fused into one shared fold) so a spec proving one
81
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
82
106
  def check(runtime, history)
83
107
  { rehydration: check_rehydration(runtime), idempotency: check_idempotency(runtime),
84
108
  value_object_round_trip: check_value_object_round_trip(history),
@@ -86,12 +110,18 @@ module Hecks
86
110
  saga_redelivery_idempotency: check_saga_idempotency(runtime, history) }
87
111
  end
88
112
 
89
- # CHECK 1 — REHYDRATE-FROM-JOURNAL == LIVE STATE.
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
90
120
  def check_rehydration(runtime)
91
121
  each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
92
122
  live = snapshot(repository)
93
123
  Dir.mktmpdir("hecks-self-consistency") do |tmp|
94
- writer = Adapters::Heki.new(aggregate: aggregate, root: tmp)
124
+ writer = guarded_heki(aggregate: aggregate, root: tmp)
95
125
  rehydrated = fold!(writer, tmp, aggregate, entries)
96
126
  next if rehydrated == live
97
127
 
@@ -101,16 +131,22 @@ module Hecks
101
131
  end
102
132
  end
103
133
 
104
- # CHECK 2 — REPLAY IDEMPOTENCY: folding the SAME entries into the
105
- # SAME durable store a second time must change nothing. A variant
134
+ # Check 2 — replay idempotency: folding the same entries into the
135
+ # same durable store a second time must change nothing. A variant
106
136
  # of check 1, worth stating separately — this catches a
107
137
  # replay-specific bug (leaked state between applications, a
108
138
  # double-applied effect) that a single, one-shot cold read could
109
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
110
146
  def check_idempotency(runtime)
111
147
  each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
112
148
  Dir.mktmpdir("hecks-self-consistency") do |tmp|
113
- writer = Adapters::Heki.new(aggregate: aggregate, root: tmp)
149
+ writer = guarded_heki(aggregate: aggregate, root: tmp)
114
150
  once = fold!(writer, tmp, aggregate, entries)
115
151
  twice = fold!(writer, tmp, aggregate, entries)
116
152
  next if once == twice
@@ -121,9 +157,9 @@ module Hecks
121
157
  end
122
158
  end
123
159
 
124
- # CHECK 3 — every `Runtime::Value` the sequence actually built
160
+ # Check 3 — every `Runtime::Value` the sequence actually built
125
161
  # (walked out of the replay's own `instances`/`events`/`queries`,
126
- # never a hand-picked example), round-tripped through the REAL
162
+ # never a hand-picked example), round-tripped through the real
127
163
  # serialize/deserialize pair: `Value#to_json` (JSON.generate(to_h),
128
164
  # value.rb) out, `Value.build` (value/coercion.rb — the same
129
165
  # constructor a command argument's own raw JSON goes through) back
@@ -132,16 +168,16 @@ module Hecks
132
168
  # `from_json` per closed set/value object); `Value.build` is the
133
169
  # actual Ruby door a raw, untyped Hash becomes a validated,
134
170
  # admitted, invariant-checked value object through.
135
- # `aggregate:` THREADED ALONGSIDE EVERY VALUE FOUND, NOT DROPPED —
171
+ # `aggregate:` threaded alongside every value found, not dropped —
136
172
  # `Value.build(value_object, fields, aggregate)`'s third argument is
137
- # what lets `normalize_composite_fields` resolve a NESTED composite
173
+ # what lets `normalize_composite_fields` resolve a nested composite
138
174
  # field's own type by name (`value_object_for(aggregate, type)`).
139
175
  # Building with `aggregate: nil` (this method's first version, live-
140
176
  # tested against `examples/pizzas` while this was being written)
141
177
  # silently skips that step entirely — `Pizza`'s own `price_cents`/
142
- # `size` fields round-tripped back as bare, STRING-keyed Hashes
178
+ # `size` fields round-tripped back as bare, string-keyed Hashes
143
179
  # instead of rebuilt `Money`/`PizzaSize` value objects, a false
144
- # POSITIVE this check would have reported as a real bug on every
180
+ # positive this check would have reported as a real bug on every
145
181
  # single sweep. Resolved from `history[:instances]`' own key
146
182
  # (`"Domain::Aggregate#id"`, `Replay#snapshot_instances`) and
147
183
  # `history[:events]`' own `event[:aggregate]` (`"Domain::Aggregate"`,
@@ -150,10 +186,16 @@ module Hecks
150
186
  # off `history` rather than a second lookup. `history[:queries]`'
151
187
  # own rows have no single owning aggregate reliably named on the
152
188
  # entry itself (a cross-aggregate read model, a `for_each` target),
153
- # so they are left OUT of this walk rather than risk the same false
189
+ # so they are left out of this walk rather than risk the same false
154
190
  # positive `nil` already produced once — `instances` and `events`
155
191
  # alone already reach every value object a generated sequence
156
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
157
199
  def check_value_object_round_trip(history)
158
200
  bluebooks = history[:bluebooks] || {}
159
201
  seen = {}.compare_by_identity
@@ -186,12 +228,12 @@ module Hecks
186
228
  end
187
229
  end
188
230
 
189
- # CHECK 4 — SAGA COLD-REHYDRATION (ANGLE-10). Checks 1/2 above cold-
190
- # read an AGGREGATE's own journal through Heki; nothing in this file
191
- # ever exercised the OTHER durable store `SagaInterpreter#checkpoint`
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`
192
234
  # writes through — `Ports::Persistence::NullSagaStore`'s own header
193
235
  # calls Heki's `SagaStore` (`adapters/driven/heki/saga_store.rb`) the
194
- # OPTIONAL saga-persistence capability, and it is real and already
236
+ # optional saga-persistence capability, and it is real and already
195
237
  # shipping, just never fuzzed: `Registry#rehydrate_sagas!` — the
196
238
  # production "process just restarted" path — folds exactly what
197
239
  # `each_saga` yields back into `@saga_instances`, and until now
@@ -201,32 +243,40 @@ module Hecks
201
243
  # rather than folding it into checks 1/2's own aggregate walk.
202
244
  #
203
245
  # `history[:saga_instances]` (`replay.rb`'s own `saga_instances`
204
- # local, built once at the very end of a replay) is the SAME
246
+ # local, built once at the very end of a replay) is the same
205
247
  # materialized `{pm_name => {correlation => {state:, memory:}}}`
206
248
  # shape `SagaInterpreter#checkpoint` itself hands a real adapter —
207
249
  # read from `history`, not re-derived from the (by-now-live, already
208
250
  # mutated by whatever `check_saga_idempotency` ran first, see that
209
251
  # method's own header) `runtime.registry.saga_instances`. Written
210
- # through a REAL `Adapters::Heki` (a throwaway tmpdir, one per
252
+ # through a real `Adapters::Heki` (a throwaway tmpdir, one per
211
253
  # process manager so two process managers with correlations that
212
254
  # happen to collide as strings never share a store), read back
213
- # through a FRESH instance (unmemoized `@store`/`@saga_store`, same
255
+ # through a fresh instance (unmemoized `@store`/`@saga_store`, same
214
256
  # reason `fold!` above uses one) — forcing the identical
215
257
  # `JSON.generate`/`JSON.parse` boundary a real crash-then-restart
216
258
  # takes, not a live-object pass-through.
217
259
  #
218
- # ONE FINDING PER (domain, process manager) — every correlation this
260
+ # One finding per (domain, process manager) — every correlation this
219
261
  # process manager's own `history[:saga_instances]` entry holds,
220
262
  # compared as a whole Hash — the same aggregate-granularity (not
221
263
  # per-record) `check_rehydration` already reports at.
222
264
  #
223
- # `completed_compensations` is DELIBERATELY OUT OF SCOPE — `history[
265
+ # `completed_compensations` is deliberately out of scope — `history[
224
266
  # :saga_instances]` never captures it (`replay.rb`'s own comment:
225
267
  # only `state`/`memory` are threaded through, since a saga's
226
268
  # in-flight compensation ledger is a fact about a leg still running,
227
269
  # not the settled snapshot this history exists to describe), so
228
270
  # there is no ground truth to compare it against here. Written as an
229
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
230
280
  def check_saga_rehydration(runtime, history)
231
281
  saga_instances = history[:saga_instances] || {}
232
282
  each_domain_process_manager(runtime).filter_map do |domain_name, process_manager|
@@ -237,7 +287,7 @@ module Hecks
237
287
  next unless anchor
238
288
 
239
289
  Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
240
- writer = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
290
+ writer = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
241
291
  persisted.each do |correlation, saga|
242
292
  writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
243
293
  state: saga[:state], memory: saga[:memory], completed_compensations: [])
@@ -253,21 +303,21 @@ module Hecks
253
303
  end
254
304
  end
255
305
 
256
- # CHECK 5 — REDELIVERY IDEMPOTENCY OF THE CHECKPOINT-THEN-LOAD PATH.
306
+ # Check 5 — redelivery idempotency of the checkpoint-then-load path.
257
307
  # `check_saga_rehydration` above proves cold-reading a checkpoint
258
- # reproduces the same DATA; this proves the OTHER half of a real
308
+ # reproduces the same data; this proves the other half of a real
259
309
  # crash/restart — a message an at-least-once delivery mechanism (an
260
310
  # outbox redrive, a queue redelivery) hands the rehydrated saga a
261
- # SECOND time — does not silently re-advance it. There is no flag
311
+ # second time — does not silently re-advance it. There is no flag
262
312
  # for this in production (`SagaInterpreter#unwind`'s own comment:
263
313
  # "the check is the guard") — the (event, current state) lookup
264
- # `handler_for` performs is the ENTIRE mechanism, and it has never
314
+ # `handler_for` performs is the entire mechanism, and it has never
265
315
  # been exercised against a state this practice loaded from cold
266
316
  # storage rather than one still sitting in a live process's memory.
267
317
  #
268
- # ONE (PROCESS MANAGER, CORRELATION) TESTED, using a fresh
318
+ # One (process manager, correlation) tested, using a fresh
269
319
  # `Runtime::SagaInterpreter` sharing `runtime`'s own `registry` and
270
- # `door: runtime` — the identical two objects the DISPATCHER'S own
320
+ # `door: runtime` — the identical two objects the dispatcher's own
271
321
  # `@sagas` was built from (`dispatcher.rb`'s own `SagaInterpreter.
272
322
  # new(registry, door: self)`) — not a hand-rolled re-implementation
273
323
  # of `advance_saga`'s own state-guard. `only: process_manager` scopes
@@ -275,13 +325,13 @@ module Hecks
275
325
  # keyword the outbox relay already uses to run one consumer alone
276
326
  # (`Runtime::Outbox::Relay#run_consumer`).
277
327
  #
278
- # WHICH EVENT TO REDELIVER — `runtime.registry.saga_log`'s own last
328
+ # Which event to redeliver — `runtime.registry.saga_log`'s own last
279
329
  # `advanced: true` row for this (process manager, correlation) names
280
- # the event BY NAME ONLY; the REAL `Runtime::Event` object (payload,
330
+ # the event by name only; the real `Runtime::Event` object (payload,
281
331
  # aggregate, id, `correlation` — everything `saga_correlation`/
282
332
  # `dispatch_args` actually read) lives in `runtime.events`, still
283
- # live for exactly this reason (this file's own header: "runtime IS
284
- # STILL LIVE HERE"). Matched back by NAME plus `saga_correlation`
333
+ # live for exactly this reason (this file's own header: "runtime is
334
+ # still live here"). Matched back by name plus `saga_correlation`
285
335
  # itself (`Runtime::SagaInterpreter::Correlation`, `private`) —
286
336
  # reused via `send` rather than reproduced, because reproducing its
287
337
  # three-tier fallback (a dotted payload field, a stamped passthrough,
@@ -291,25 +341,25 @@ module Hecks
291
341
  # its own `saga_log` row's `on:` is the synthetic `REFUSED` trigger
292
342
  # name, never a real domain event, so there is nothing to redeliver.
293
343
  #
294
- # SIMULATING "JUST RESTARTED" — the live registry's own in-memory
295
- # `saga_instances[pm][correlation]` slot is overwritten, IN PLACE,
296
- # with whatever a cold Heki read of the SAME checkpoint answers
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
297
347
  # (exactly what `Registry#rehydrate_sagas!` does for real on every
298
348
  # boot), the redelivery is driven through the real interpreter, and
299
349
  # the slot is put back — `ensure`d — once this correlation's own
300
- # check is done. Safe ONLY because `check`/`Replay.call` run this,
350
+ # check is done. Safe only because `check`/`Replay.call` run this,
301
351
  # synchronously, single-threaded, as the very last thing before
302
352
  # `runtime` and its whole tmp directory go out of scope for good;
303
- # nothing downstream of this method ever reads the LIVE registry
353
+ # nothing downstream of this method ever reads the live registry
304
354
  # again (`check_saga_rehydration`, `check_rehydration`, `check_
305
355
  # idempotency`, `check_value_object_round_trip` all read `history`'s
306
356
  # own frozen snapshot instead, never `runtime.registry` — so calling
307
357
  # order relative to this method's own mutation doesn't matter).
308
358
  #
309
- # THE ASSERTION IS ABOUT `state`/`memory`, NOT "did a dispatch fire"
310
- # — a leg whose own `from:`/`to:` are the SAME state (every existing
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
311
361
  # saga's own starts_on self-transition, `waybill.bluebook`'s own leg
312
- # 1/2) is EXPECTED to re-run on redelivery with no visible state
362
+ # 1/2) is expected to re-run on redelivery with no visible state
313
363
  # change at all; that is a property of the declared handler graph,
314
364
  # not a rehydration defect, and asserting against it here would
315
365
  # manufacture a false positive on every saga this corpus has. A
@@ -317,6 +367,14 @@ module Hecks
317
367
  # the redelivered event name (the ordinary, expected case once a
318
368
  # saga has moved past the leg that produced its own current
319
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
320
378
  def check_saga_idempotency(runtime, history)
321
379
  saga_instances = history[:saga_instances] || {}
322
380
  interpreter = Runtime::SagaInterpreter.new(runtime.registry, door: runtime)
@@ -340,18 +398,18 @@ module Hecks
340
398
 
341
399
  # ── Rust-side self-consistency ───────────────────────────────────
342
400
  #
343
- # THE COMPILED BINARY'S OWN REHYDRATION DOOR, ALREADY SHIPPING —
344
- # `kernel/cli.rs`'s `run` accepts an OPTIONAL top-level `"seed"` key
345
- # ("the exact 'Domain::Aggregate#id' -> state shape THIS run's own
346
- # 'instances' output already produces... lets a HOST seed prior
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
347
405
  # state back in instead of replaying `steps` from scratch every
348
406
  # invocation"). `Store::from_seed`/`Store::instances` are that
349
407
  # mechanism's own two halves — `rust/host` (docs/implemented/
350
408
  # decisions/0012) depends on them being true inverses for real,
351
- # today. `rust_seed_round_trip` exercises exactly that: a SECOND,
352
- # independent invocation of the SAME binary, `"steps": []` (nothing
409
+ # today. `rust_seed_round_trip` exercises exactly that: a second,
410
+ # independent invocation of the same binary, `"steps": []` (nothing
353
411
  # new dispatched — this is rehydration, not re-dispatch), seeded
354
- # with whatever `"instances"` a PRIOR invocation already produced.
412
+ # with whatever `"instances"` a prior invocation already produced.
355
413
  # `differ` (unused, kept in the signature — see below) is a
356
414
  # `RustConformanceHelpers`-including instance (`bin/qa_sweep`'s own
357
415
  # `Differ`), the same argument `check_rust_rehydration`/`check_
@@ -359,27 +417,33 @@ module Hecks
359
417
  # rather than dropped from all three signatures at once so a
360
418
  # future differential-style reduction has a door already open,
361
419
  # without this module ever `require`ing `spec/support/` itself.
362
- # NEITHER `strip_emitted_flags!` NOR ANY OTHER DIFFERENTIAL-ONLY
363
- # REDUCTION RUNS HERE — that reduction exists so a Rust-only
420
+ # Neither `strip_emitted_flags!` nor any other differential-only
421
+ # reduction runs here — that reduction exists so a Rust-only
364
422
  # bookkeeping field (`emitted_<event>`, docs/decisions/0049) never
365
423
  # counts against Ruby, which has no equivalent field to agree with
366
424
  # at all (`RustConformanceHelpers#strip_emitted_flags!`'s own
367
425
  # comment). This check has no Ruby side to spare — it is asking the
368
- # Rust binary whether it agrees with ITSELF, so `emitted_*` fields
426
+ # Rust binary whether it agrees with itself, so `emitted_*` fields
369
427
  # are exactly as real a fact to compare as any other. Stripping them
370
- # here (an earlier version of this method did) silently deleted
371
- # them from the RETURNED seed-round-trip result while leaving them
372
- # present on the ORIGINAL `seed_instances` a caller passes in — an
373
- # asymmetric comparison that reported EVERY record carrying one as
374
- # a rehydration divergence, unconditionally, on every domain that
375
- # has one at all. Found live against `examples/banking`
376
- # (`Banking::Account`'s own `corrects` reaction) while this
377
- # integration was being written, by comparing this method's own
378
- # answer against the compiled binary's RAW stdout for the identical
379
- # seed call: the raw round trip preserved `emitted_fee_applied`
380
- # correctly; only THIS method's own stripping dropped it. `spec/
381
- # self_consistency_rust_spec.rb`'s own "banking" example pins the
382
- # regression against a real domain going forward.
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"`
383
447
  def rust_seed_round_trip(binary, _differ, seed_instances)
384
448
  stdout, status = Open3.capture2(binary, stdin_data: JSON.generate({ "steps" => [], "seed" => seed_instances }))
385
449
  return { "__self_consistency_error__" => "rust binary exited #{status.exitstatus}: #{stdout}" } \
@@ -391,11 +455,18 @@ module Hecks
391
455
  parsed["instances"]
392
456
  end
393
457
 
394
- # CHECK 1, RUST SIDE — seeding a fresh invocation with a PRIOR
458
+ # Check 1, Rust side — seeding a fresh invocation with a prior
395
459
  # invocation's own live `"instances"` must reproduce that same
396
460
  # state, unchanged. `live_instances` is `rust_output["instances"]`
397
461
  # — the exact same value `bin/qa_sweep`'s own differential compare
398
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
399
470
  def check_rust_rehydration(binary, differ, live_instances)
400
471
  rehydrated = rust_seed_round_trip(binary, differ, live_instances)
401
472
  return [] if rehydrated == live_instances
@@ -403,11 +474,19 @@ module Hecks
403
474
  [{ field: "rust_rehydration", live: live_instances, rehydrated: rehydrated }]
404
475
  end
405
476
 
406
- # CHECK 2, RUST SIDE — seeding with what a first seed round trip
407
- # already produced, a SECOND time, must not drift any further. The
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
408
479
  # same "replay it again, byte for byte" claim `check_idempotency`
409
480
  # proves for Ruby, aimed at the one rehydration door this compiled
410
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
411
490
  def check_rust_idempotency(binary, differ, live_instances)
412
491
  once = rust_seed_round_trip(binary, differ, live_instances)
413
492
  twice = rust_seed_round_trip(binary, differ, once)
@@ -418,12 +497,18 @@ module Hecks
418
497
 
419
498
  # ── shared plumbing ─────────────────────────────────────────────
420
499
 
421
- # EVERY [domain, aggregate] PAIR THIS SEQUENCE ACTUALLY WROTE TO —
500
+ # Every [domain, aggregate] pair this sequence actually wrote to —
422
501
  # an aggregate with an empty `#entries` never had anything dispatch
423
502
  # against it this run, so there is nothing to rehydrate and no
424
503
  # finding a "clean, nothing touched" report would mean anything
425
504
  # for. Mirrors `Replay#snapshot_instances`' own
426
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
427
512
  def each_touched_repository(runtime)
428
513
  found = []
429
514
  runtime.registry.bluebooks.each do |domain_name, bluebook|
@@ -438,21 +523,32 @@ module Hecks
438
523
  found
439
524
  end
440
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
441
532
  def snapshot(repository)
442
533
  repository.all.to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
443
534
  end
444
535
 
445
- # EVERY [domain, process manager] PAIR ANY LOADED BLUEBOOK DECLARES —
536
+ # Every [domain, process manager] pair any loaded bluebook declares —
446
537
  # regardless of whether this replay's own `history[:saga_instances]`
447
538
  # ever touched it (mirrors `each_touched_repository`'s own walk one
448
539
  # level up; the "did anything actually persist" filter lives in each
449
540
  # check's own caller, same as that method's `entries.empty?` guard).
450
- # An empty return here IS the "domain declares no process manager"
541
+ # An empty return here is the "domain declares no process manager"
451
542
  # skip `check_saga_rehydration`/`check_saga_idempotency` both need —
452
543
  # `filter_map`/`flat_map` over an empty Array already answers `[]`,
453
544
  # identical to "ran and found nothing," which is deliberate: neither
454
545
  # check has a positive "passed" artifact to report either way (see
455
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
456
552
  def each_domain_process_manager(runtime)
457
553
  found = []
458
554
  runtime.registry.bluebooks.each do |domain_name, bluebook|
@@ -461,50 +557,67 @@ module Hecks
461
557
  found
462
558
  end
463
559
 
464
- # THE LIVE-SIDE GROUND TRUTH, key-shape-normalized (see `deep_
560
+ # The live-side ground truth, key-shape-normalized (see `deep_
465
561
  # stringify_keys`'s own comment) so it compares fairly against a
466
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
467
568
  def normalize_saga_rows(persisted)
468
569
  persisted.each_with_object({}) do |(correlation, saga), rows|
469
570
  rows[correlation.to_s] = { state: saga[:state], memory: deep_stringify_keys(saga[:memory]) }
470
571
  end
471
572
  end
472
573
 
473
- # A FRESH `Adapters::Heki` AT THE SAME `tmp`/`domain` — unmemoized
574
+ # A fresh `Adapters::Heki` at the same `tmp`/`domain` — unmemoized
474
575
  # `@store`/`@saga_store`, so `#each_saga` is forced back through
475
576
  # `read_snapshot`/`replay_journal`, real bytes off real disk, not
476
577
  # whatever the writer that just wrote them still holds in its own
477
- # process memory (the same reason `fold!`, above, opens a second
578
+ # process memory (the same reason `fold!`, below, opens a second
478
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
479
588
  def cold_read_saga_rows(anchor, tmp, domain_name)
480
- reader = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
589
+ reader = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
481
590
  reader.each_saga.with_object({}) do |(_pm, correlation, state, memory, _completed), rows|
482
591
  rows[correlation] = { state: state, memory: deep_stringify_keys(memory) }
483
592
  end
484
593
  end
485
594
 
486
- # `SagaStore#each_saga` ONLY EVER SYMBOLIZES `memory`'S OWN TOP-LEVEL
487
- # KEYS (`heki/saga_store.rb`'s own `each_saga`, one level deep) —
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) —
488
597
  # `Registry::SagaPersistence#warn_stalled_saga` already documents
489
598
  # this exact asymmetry for the one reserved key production code
490
- # cares about (`SAGA_PENDING_DISPATCH_KEY`). A NESTED composite
599
+ # cares about (`SAGA_PENDING_DISPATCH_KEY`). A nested composite
491
600
  # memory field (any saga whose starting event carries a value
492
601
  # object, which is most of them — `waybill.bluebook`'s own
493
- # `ConsignmentRequested` alone has three) comes back with STRING
494
- # keys at every level BELOW the top, while `history[:saga_instances]`
495
- # 's own `Runtime::Value.materialize` call produces SYMBOL keys
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
496
605
  # throughout. That asymmetry is Heki's own documented, accepted
497
606
  # storage convention — an "opaque, adapter-agnostic JSON blob"
498
607
  # (`SagaInterpreter#checkpoint`'s own comment), never a typed
499
- # rebuild the way an AGGREGATE's own composite fields get on cold
608
+ # rebuild the way an aggregate's own composite fields get on cold
500
609
  # read (this file's own header: there is no VO schema to rebuild
501
610
  # against for a saga's memory blob at all) — not a rehydration
502
611
  # defect this check exists to find. Recursively re-stringifying
503
- # BOTH sides before comparing is what tells that KNOWN, accepted
504
- # shape difference apart from an ACTUAL data-loss bug (a dropped
612
+ # both sides before comparing is what tells that known, accepted
613
+ # shape difference apart from an actual data-loss bug (a dropped
505
614
  # key, a changed value, a missing field) — exactly the kind (b)'s
506
615
  # own seeded fixture in `spec/fuzzing/self_consistency_saga_spec.rb`
507
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
508
621
  def deep_stringify_keys(value)
509
622
  case value
510
623
  when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
@@ -513,7 +626,7 @@ module Hecks
513
626
  end
514
627
  end
515
628
 
516
- # THE REAL, ALREADY-ANNOUNCED EVENT this correlation's CURRENT
629
+ # The real, already-announced event this correlation's current
517
630
  # checkpoint came from — walked back out of `runtime.registry.
518
631
  # saga_log`'s own `advanced: true` rows (newest first), skipping the
519
632
  # synthetic `REFUSED` trigger (`Runtime::SagaInterpreter::REFUSED`
@@ -530,8 +643,19 @@ module Hecks
530
643
  # payload field, a stamped passthrough, a self-identifying
531
644
  # `event.id`) here rather than reusing it would be exactly the
532
645
  # "hand-rolled approximation" this check exists to avoid; `send` on
533
- # an interpreter sharing this SAME `runtime`'s own registry is the
646
+ # an interpreter sharing this same `runtime`'s own registry is the
534
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
535
659
  def last_advancing_event(runtime, interpreter, process_manager, correlation)
536
660
  entry = runtime.registry.saga_log.reverse_each.find do |row|
537
661
  row[:process_manager] == process_manager.name && row[:instance] == correlation &&
@@ -544,20 +668,39 @@ module Hecks
544
668
  end
545
669
  end
546
670
 
547
- # ONE (process manager, correlation)'s OWN redelivery check — pulled
671
+ # One (process manager, correlation)'s own redelivery check — pulled
548
672
  # out of `check_saga_idempotency` itself so that method's own
549
673
  # `flat_map`/`filter_map` walk stays readable; every local this
550
674
  # shares with its caller (`interpreter`, `anchor`) is passed in
551
- # rather than re-derived.
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
552
694
  def check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
553
695
  correlation, saga, redelivery)
696
+ # rubocop:disable-next Metrics/BlockLength
554
697
  Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
555
- writer = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
698
+ writer = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
556
699
  writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
557
700
  state: saga[:state], memory: saga[:memory], completed_compensations: [])
558
701
 
559
- rehydrated = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
560
- .each_saga.find { |_pm, corr, *| corr == correlation.to_s }
702
+ rehydrated = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
703
+ .each_saga.find { |_pm, corr, *| corr == correlation.to_s }
561
704
  next unless rehydrated
562
705
 
563
706
  _pm, _corr, state, memory, compensations = rehydrated
@@ -565,6 +708,17 @@ module Hecks
565
708
 
566
709
  saga_instances = runtime.registry.saga_instances[process_manager.name]
567
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
568
722
  begin
569
723
  saga_instances[correlation] = { state: state, memory: memory, completed_compensations: compensations || [] }
570
724
  interpreter.advance(redelivery, domain_name, only: process_manager)
@@ -576,6 +730,8 @@ module Hecks
576
730
  { field: "saga_redelivery_idempotency", domain: domain_name, process_manager: process_manager.name,
577
731
  correlation: correlation, on: redelivery.name, before: before, after: after_shape }
578
732
  ensure
733
+ saga_log.slice!(saga_log_mark..) if saga_log.size > saga_log_mark
734
+
579
735
  if original
580
736
  saga_instances[correlation] = original
581
737
  else
@@ -585,47 +741,69 @@ module Hecks
585
741
  end
586
742
  end
587
743
 
588
- # ONE FOLD OF `entries` INTO `writer` (a real, already-open `Heki`
589
- # adapter at `tmp`), THEN A COLD READ BACK through a BRAND NEW `Heki`
590
- # instance at the SAME path — `@store` on a fresh instance starts
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
591
747
  # unmemoized, so `#all` below is forced through `#read` →
592
748
  # `#read_snapshot`/`#replay_journal`, real disk bytes, not whatever
593
749
  # `writer` still holds cached in its own process memory. Called
594
- # TWICE in a row against the SAME `writer` (see `check`/
750
+ # twice in a row against the same `writer` (see `check`/
595
751
  # `check_idempotency` above) is exactly "replay the same journal a
596
752
  # second time" — `writer` already holds everything the first fold
597
753
  # wrote, so a second fold re-applies the identical operations on
598
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
599
762
  def fold!(writer, tmp, aggregate, entries)
600
763
  entries.each do |entry|
601
764
  writer.append(entry)
602
765
  writer.project(entry)
603
766
  end
604
767
 
605
- Adapters::Heki.new(aggregate: aggregate, root: tmp).all
606
- .to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
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
607
774
  end
608
775
 
609
- # RECURSES THROUGH A `Value`'S OWN FIELDS VIA `#[]`, NOT `#to_h` —
776
+ # Recurses through a `Value`'s own fields via `#[]`, not `#to_h` —
610
777
  # `#to_h` already materializes every field (`Value.materialize`),
611
778
  # which would hide a nested `Value` from this walk before it ever
612
- # got here. In practice a value object's OWN composite fields
779
+ # got here. In practice a value object's own composite fields
613
780
  # (`Coercion#normalize_composite_fields`) are validated but stored
614
781
  # as plain, already-materialized Hashes, not re-wrapped `Value`
615
782
  # instances — confirmed live, not assumed — so this recursion finds
616
- # nothing further past the field it started from FOR TODAY'S
783
+ # nothing further past the field it started from for today's
617
784
  # coercion pipeline specifically. Kept anyway, not dead code: the
618
785
  # generic `Hash`/`Array` branches below reach the exact same nested
619
786
  # data through `node[attribute.name]` regardless, and a future
620
- # change that DOES start wrapping composite fields as real `Value`
787
+ # change that does start wrapping composite fields as real `Value`
621
788
  # instances would be walked correctly here with no change needed.
622
- # `seen` is a `compare_by_identity` Hash: the same INSTANCE can legitimately
789
+ # `seen` is a `compare_by_identity` Hash: the same instance can legitimately
623
790
  # appear more than once (an aggregate's live state and an event
624
791
  # payload both reference the exact same frozen object), and
625
792
  # checking it twice would just waste time, never change the
626
793
  # answer — identity, not `Value#==`, is the right notion of
627
- # "already found" here (two DIFFERENT value objects that happen to
794
+ # "already found" here (two different value objects that happen to
628
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]
629
807
  def walk_value_objects(node, found, seen, aggregate)
630
808
  case node
631
809
  when Runtime::Value