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