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
@@ -9,19 +9,19 @@ require_relative "../ports/persistence/remote_runtime"
9
9
 
10
10
  module Hecks
11
11
  module Runtime
12
- # THE WRITE-SIDE HALF OF LAMBDA ROUTING — `Runtime::Loader.boot`
13
- # constructs this INSTEAD OF `Dispatcher` when the booted domain's
12
+ # **The write-side half of lambda routing** — `Runtime::Loader.boot`
13
+ # constructs this instead of `Dispatcher` when the booted domain's
14
14
  # own `.world` declares Lambda routing (see loader.rb's own
15
15
  # `dispatcher_class_for`). Matches `Dispatcher`'s public shape
16
- # closely enough that everything built ON TOP of a dispatcher —
16
+ # closely enough that everything built on top of a dispatcher —
17
17
  # `Handle`, `AggregateDoor`, `Facade::Surface` — needs no changes
18
18
  # at all: `Handle#run`'s entire contract is
19
- # `@dispatcher.dispatch("#{fqn}.#{command}", **identity, **args).instance.state`,
19
+ # `@dispatcher.dispatch("#{fqn}.#{command}", to: @id, with: args).instance.state`,
20
20
  # and both classes answer that identically.
21
21
  #
22
- # READS DELEGATE, WRITES DON'T. `query`/`reference_query` hand off
23
- # to a REAL `Dispatcher` built over the SAME registry — since that
24
- # registry's own repositories are ALREADY Lambda-backed
22
+ # **Reads delegate, writes don't**. `query`/`reference_query` hand off
23
+ # to a real `Dispatcher` built over the same registry — since that
24
+ # registry's own repositories are already Lambda-backed
25
25
  # (`Adapters::Lambda`, `persisted_by("Lambda")`, Phase 2's other
26
26
  # half), the inherited query machinery (`QueryInterpreter`,
27
27
  # `Ports::Query::InMemory.execute`) works completely unchanged, no
@@ -31,49 +31,120 @@ module Hecks
31
31
  # against incomplete local state and then merely persisted.
32
32
  class RemoteDispatcher
33
33
  Result = Struct.new(:verb, :instance, :events, keyword_init: true) do
34
+ # Reads the identity of the record the dispatch settled on.
35
+ #
36
+ # @return [String] the settled record's identity
34
37
  def id = instance.id
38
+
39
+ # Reads the settled record's attributes as one Hash.
40
+ #
41
+ # @return [Hash{Symbol => Object}] the settled record's attributes, `:id` merged
42
+ # in last
35
43
  def state = instance.to_h
36
44
  end
37
45
 
38
46
  attr_reader :registry
39
47
 
40
- def initialize(registry, region: "us-east-1")
48
+ # @param registry [Runtime::Registry] the booted registry this dispatcher fronts;
49
+ # read-side calls (`query`/`reference_query`) delegate to a local `Dispatcher`
50
+ # built over the same registry
51
+ # @param region [String] the AWS region the routed Lambda function lives in
52
+ # @param function [String, nil] the `.world`'s own `dispatched_by("Lambda")`
53
+ # function name, when the deployment stack is not named `hecks-<domain>`; nil
54
+ # resolves the function name from `ENV["DOMAIN_NAME"]` or `registry.root`
55
+ def initialize(registry, region: "us-east-1", function: nil)
41
56
  @registry = registry
42
57
  # `File.basename(registry.root)`, not `bluebooks.keys.first` —
43
58
  # matches `Adapters::Lambda`'s own function-name resolution
44
59
  # exactly (see its own comment on why: one merged Lambda per
45
60
  # deploy, not one per attached chapter, and `root` is the one
46
61
  # signal every bluebook in this registry shares regardless of
47
- # which one attached it) — INCLUDING that same adapter's own
62
+ # which one attached it) — including that same adapter's own
48
63
  # `ENV["DOMAIN_NAME"]`-first fix: `root` is always `/var/task`
49
64
  # inside a deployed Lambda, giving "task" instead of the real
50
65
  # domain name (a real, live AccessDeniedException on
51
66
  # "hecks-task" caught this).
52
- @client = Adapters::Lambda::Client.new(domain: ENV["DOMAIN_NAME"] || File.basename(registry.root), region: region)
53
- # READ-SIDE DELEGATE ONLY (see class comment) — never dispatched
67
+ # `function:` — the `.world`'s own `dispatched_by("Lambda")`
68
+ # naming of which function this is, for a deployment whose stack
69
+ # name isn't `hecks-<domain>` (Client's own comment has the real
70
+ # case). Absent, the resolution above is unchanged.
71
+ @client = Adapters::Lambda::Client.new(domain: ENV["DOMAIN_NAME"] || File.basename(registry.root),
72
+ region: region, function: function)
73
+ # Read-side delegate only (see class comment) — never dispatched
54
74
  # through; a real Dispatcher's own `query`/`reference_query`
55
75
  # already resolve generically via `registry.repository(...)`,
56
76
  # so building one here reuses that instead of duplicating it.
57
77
  @local = Dispatcher.new(registry)
58
78
  end
59
79
 
80
+ # Dispatches a command by verb, routing to the local `Dispatcher` or the
81
+ # remote Lambda depending on the aggregate's bound adapter.
82
+ #
83
+ # Same shape as `Dispatcher#dispatch_flat` — everything but
84
+ # `saga_correlation:` is forwarded through unread, `to:`/`with:`
85
+ # included, and lifted out downstream by whichever path actually
86
+ # dispatches (`@local.dispatch_flat` locally, the flat wire form
87
+ # remotely). Not the strict `to:`/`with:`-only door `Dispatcher#
88
+ # dispatch` is — see that class's own comment for why this file
89
+ # never had one.
90
+ #
91
+ # @param verb [String] the fully qualified verb, `"Domain::Aggregate.Command"`
92
+ # or `"Domain::Aggregate.Entity.Command"`
93
+ # @param saga_correlation [Hash, nil] correlation head => value, stamped on every
94
+ # emitted event when a saga leg causes this dispatch; nil otherwise
95
+ # @param args [Hash] the facts, plus optional `:to`/`:with` keys, read the same
96
+ # way `dispatch_flat` reads them
97
+ # @return [RemoteDispatcher::Result] the verb, settled instance and emitted events
98
+ # @raise [Runtime::UnknownVerb] if the verb is not fully qualified, or names a
99
+ # domain or aggregate that is not declared
100
+ # @raise [StandardError] any class in `Runtime::DOMAIN_REFUSALS` when a
101
+ # locally-routed aggregate refuses the call
102
+ # @raise [Runtime::StaleWrite] if concurrent local writers beat this one through
103
+ # every retry, for a locally-routed aggregate
104
+ # @raise [Runtime::WiringError] if the aggregate's adapter or repository cannot be
105
+ # resolved, or the remote call is accepted but reports no mutation for it
106
+ # @raise [Runtime::RemoteRefusal] if the routed Lambda refuses the call
60
107
  def dispatch(verb, saga_correlation: nil, **args)
108
+ dispatch_flat(verb, args.merge(saga_correlation: saga_correlation))
109
+ end
110
+
111
+ # Routes a dispatch to the local `Dispatcher` when the aggregate's bound
112
+ # adapter is not remote-backed (`Ports::Persistence::RemoteRuntime`),
113
+ # otherwise dispatches through the routed Lambda. Same flat-facts wire
114
+ # form as `Dispatcher#dispatch_flat`.
115
+ #
116
+ # @param verb [String] the fully qualified verb, in any shape `dispatch` accepts
117
+ # @param args [Hash] the facts, plus optional Symbol keys `:to`, `:with` and
118
+ # `:saga_correlation`, read as `dispatch`'s keywords of the same names; not mutated
119
+ # @return [RemoteDispatcher::Result] the verb, settled instance and emitted events
120
+ # @raise [Runtime::UnknownVerb] if the verb is not fully qualified, or names a
121
+ # domain or aggregate that is not declared
122
+ # @raise [StandardError] any class in `Runtime::DOMAIN_REFUSALS` when a
123
+ # locally-routed aggregate refuses the call
124
+ # @raise [Runtime::StaleWrite] if concurrent local writers beat this one through
125
+ # every retry, for a locally-routed aggregate
126
+ # @raise [Runtime::WiringError] if the aggregate's adapter or repository cannot be
127
+ # resolved, or the remote call is accepted but reports no mutation for it
128
+ # @raise [Runtime::RemoteRefusal] if the routed Lambda refuses the call
129
+ def dispatch_flat(verb, args = {})
130
+ args = args.dup
131
+ saga_correlation = args.delete(:saga_correlation)
61
132
  domain, aggregate_name, = Naming.split_verb(verb) ||
62
133
  raise(UnknownVerb,
63
- RefusalWording.render("UnknownVerb", "not_fully_qualified", verb: verb.inspect))
134
+ RefusalWording.render_site("UnknownVerb", "not_fully_qualified", verb: verb))
64
135
  aggregate = @registry.bluebook(domain)&.aggregate(aggregate_name) ||
65
136
  raise(UnknownVerb,
66
- RefusalWording.render("UnknownVerb", "no_aggregate", domain: domain, aggregate: aggregate_name.inspect))
137
+ RefusalWording.render_site("UnknownVerb", "no_aggregate", domain: domain, aggregate: aggregate_name))
67
138
 
68
- # NOT EVERY AGGREGATE IN A LAMBDA-ROUTED DOMAIN IS ITSELF
69
- # LAMBDA-BOUND — Member's real name->email rekey carries a
139
+ # Not every aggregate in a lambda-routed domain is itself
140
+ # lambda-bound — Member's real name->email rekey carries a
70
141
  # `compute` rule (era_check.rb's own `check_compute_rules!`),
71
142
  # which can only ever run against Postgres, permanently. Its
72
- # OWN `.hecksagon` bind stays "Postgres" even when
143
+ # own `.hecksagon` bind stays "Postgres" even when
73
144
  # `dispatched_by("Lambda")` is on for everything else — checked
74
- # here by real CAPABILITY (`Ports::Persistence::RemoteRuntime`,
145
+ # here by real capability (`Ports::Persistence::RemoteRuntime`,
75
146
  # §1), not by comparing the adapter's own name to the string
76
- # "Lambda" — a bind resolves to whatever adapter CLASS actually
147
+ # "Lambda" — a bind resolves to whatever adapter class actually
77
148
  # backs it, and only a class shaped like "the real interpreter
78
149
  # lives behind a call boundary" forwards here; anything else
79
150
  # (Postgres, Memory, any future local adapter) falls through to
@@ -82,7 +153,7 @@ module Hecks
82
153
  # all.
83
154
  adapter_name = Ports::Persistence::BindingPolicy.resolve(@registry, domain, aggregate).adapter
84
155
  unless @registry.adapter_class(adapter_name) <= Ports::Persistence::RemoteRuntime
85
- return @local.dispatch(verb, saga_correlation: saga_correlation, **args)
156
+ return @local.dispatch_flat(verb, args.merge(saga_correlation: saga_correlation))
86
157
  end
87
158
 
88
159
  response = @client.dispatch(verb, args)
@@ -90,14 +161,14 @@ module Hecks
90
161
  refusal = response.fetch("refusals", []).find { |r| r["verb"] == verb }
91
162
  raise RemoteRefusal, "#{verb} refused: #{refusal['error']}" if refusal
92
163
 
93
- # THIS STEP'S OWN mutations — `mutations` is one entry per
164
+ # This step's own mutations — `mutations` is one entry per
94
165
  # replayed step (rust/host's rehydrate-and-replay design,
95
166
  # Phase 1), so `.last` is exactly the step just dispatched.
96
167
  # Matched by fully-qualified aggregate name, not just "the
97
- # first mutation" — a command whose reaction ALSO mutates a
168
+ # first mutation" — a command whose reaction also mutates a
98
169
  # different aggregate (a policy, a saga leg) puts more than
99
170
  # one mutation in the same step, and the direct effect of
100
- # THIS verb is the one this dispatch's own caller expects
171
+ # this verb is the one this dispatch's own caller expects
101
172
  # `.instance` to be.
102
173
  fqn = "#{domain}::#{aggregate.hecks_name}"
103
174
  mutation = response.fetch("mutations", []).last&.find { |m| m["aggregate"] == fqn } ||
@@ -110,16 +181,46 @@ module Hecks
110
181
  Result.new(verb: verb, instance: instance, events: step_events(response))
111
182
  end
112
183
 
184
+ # Delegates to the local `Dispatcher` built over the same registry — see the
185
+ # class comment on why reads, unlike writes, need no remote-specific logic.
186
+ #
187
+ # @param verb [String, Symbol] the query's verb, in one of `Dispatcher#query`'s
188
+ # three shapes
189
+ # @param args [Hash{Symbol => Object}] the query's declared arguments
190
+ # @return [Array<Hash>] see `Dispatcher#query`'s own return
191
+ # @raise [Runtime::UnknownVerb] if the verb is not fully qualified, or names a
192
+ # domain, aggregate, entity, query or read model that is not declared
193
+ # @raise [Runtime::NotFound] if a read model's root reference names no record
194
+ # @raise [Runtime::TypeMismatch] if an argument cannot be coerced to its declared type
195
+ # @raise [KeyError] if a rooted read model is asked without its reference argument
196
+ # @raise [Runtime::WiringError] if the aggregate's repository cannot be resolved
113
197
  def query(verb, **args) = @local.query(verb, **args)
198
+
199
+ # Delegates to the local `Dispatcher` built over the same registry — see the
200
+ # class comment on why reads, unlike writes, need no remote-specific logic.
201
+ #
202
+ # @param verb [String] the fully qualified query verb, `"Domain::Aggregate.Query"`
203
+ # or `"Domain::Aggregate.Entity.Query"`
204
+ # @param args [Hash{Symbol => Object}] the query's declared arguments
205
+ # @return [Array<Hash>] one Hash per matching record, its state with `:id` merged
206
+ # in last; for an entity query, one Hash per matching element
207
+ # @raise [Runtime::UnknownVerb] if the verb is not fully qualified, or names a
208
+ # domain, aggregate, entity or query that is not declared
209
+ # @raise [Runtime::TypeMismatch] if an argument cannot be coerced to its declared type
210
+ # @raise [Runtime::WiringError] if the aggregate's repository cannot be resolved
114
211
  def reference_query(verb, **args) = @local.reference_query(verb, **args)
115
212
 
116
- # THE FULL DOMAIN'S EVENT HISTORY, on every call — `{"read":
213
+ # Fetches the full domain's event history, on every call — `{"read":
117
214
  # true}` replays the whole journal (Phase 1's `dispatch::read`),
118
- # so its own `events` array already IS the complete log, the
215
+ # so its own `events` array already is the complete log, the
119
216
  # same thing `@registry.event_log` would answer for a local
120
217
  # dispatch. Not cached: `AggregateDoor.events`/`Handle#events`
121
218
  # are not called in this codebase's own hot paths today: if that
122
219
  # changes, caching belongs here, not in every caller.
220
+ #
221
+ # @return [Array<Runtime::Event>] every event in the routed Lambda's domain
222
+ # journal, oldest first, with `occurred_at` always nil (the kernel is
223
+ # timestamp-free by design)
123
224
  def events
124
225
  @client.read.fetch("events", []).map { |e| build_event(e) }
125
226
  end
@@ -1,14 +1,18 @@
1
- require_relative "errors"
2
- require_relative "refusal_wording"
3
- require_relative "../rendering"
4
-
5
1
  module Hecks
6
2
  module Runtime
7
3
  # The invocation address is not part of a command's domain payload.
8
4
  # Aggregate commands carry one receiver identity; entity commands carry
9
5
  # the aggregate receiver followed by one identity for every entity hop.
6
+ #
7
+ # Reading a call's shape — what `to:` and `with:` mean, the BUG#7/#17/#18
8
+ # rules — lives in `Runtime::Invocation.from_call` now (invocation.rb).
9
+ # `envelope`/`payload` are kept as thin delegators for any caller still
10
+ # naming them; the dispatcher itself builds an Invocation instead.
10
11
  module Routing
11
12
  Envelope = Struct.new(:aggregate, :entities, keyword_init: true) do
13
+ # @param aggregate [String, #to_s] the receiving aggregate's identity
14
+ # @param entities [Array<String, #to_s>, nil] the entity-hop identities after the
15
+ # aggregate, in call order; empty for an aggregate-level command
12
16
  def initialize(aggregate:, entities: [])
13
17
  super(aggregate: aggregate.to_s, entities: Array(entities).map(&:to_s).freeze)
14
18
  freeze
@@ -17,158 +21,36 @@ module Hecks
17
21
 
18
22
  module_function
19
23
 
20
- def envelope(to, entity_depth: 0)
21
- return nil if to.nil?
22
-
23
- aggregate, entities = to.is_a?(Hash) ? parse_envelope_hash(to) : scalar_envelope(to)
24
-
25
- raise TypeMismatch, "to: must name the receiving aggregate identity" if aggregate.nil? || aggregate.to_s.empty?
26
- if entities.size != entity_depth
27
- raise TypeMismatch,
28
- "to: for an entity command needs #{entity_depth} entity " \
29
- "#{entity_depth == 1 ? 'identity' : 'identities'} after the aggregate — got #{entities.size}"
30
- end
31
- raise TypeMismatch, "to: contains a blank entity identity" if entities.any? do |identity|
32
- identity.nil? || identity.to_s.empty?
33
- end
34
-
35
- Envelope.new(aggregate: aggregate, entities: entities)
36
- end
37
-
38
- # BUG#7 — the non-Hash half of `to:` used to accept ANY Ruby object
39
- # (`[to, []]`, unconditionally) as though it were a ready-made
40
- # aggregate identity scalar. That is looser than the JSON step
41
- # boundary (`bin/run`/`Fuzzing::Replay`/`StepBuilder`) ever needs it
42
- # to be — every legitimate caller already hands this a `String`
43
- # (`Naming.identity` canonicalizes every identity to one before it
44
- # ever reaches a `to:`/legacy-args door) — and looser than Rust's own
45
- # hand-written mirror of this exact boundary
46
- # (`rust/src/kernel/routing.rs#RoutingEnvelope::from_json`, its own
47
- # header: "Generated routers accept this shape while retaining the
48
- # legacy mixed-args object as a compatibility input during
49
- # migration"), which refuses anything that is neither a JSON string
50
- # nor object outright, TypeMismatch, before ever reaching a domain's
51
- # own command payload.
24
+ # Resolves a `to:` argument into a routing envelope. A thin delegator to
25
+ # `Invocation.route`; the dispatcher itself builds an `Invocation` instead.
52
26
  #
53
- # The gap surfaced live on `examples/roster` — the first domain in
54
- # this corpus to declare a command attribute literally named `to`
55
- # (`Roster::Roster.Mark`, deliberately, per that bluebook's own
56
- # header comment). `bin/qa_sweep`'s legacy-args dispatch convention
57
- # (`runtime.dispatch(verb, **symbolize(args))`, `step_builder.rb`)
58
- # flattens a command's own declared fact and its routing target into
59
- # ONE Ruby kwargs hash — completely ordinary for every other domain,
60
- # since Ruby's keyword-argument binding only steals a key that
61
- # collides with `dispatch`'s own `to:`/`with:`/`saga_correlation:`
62
- # parameter names. `Mark`'s `to` does collide, so a fuzzer-corrupted
63
- # scalar offered for it (an out-of-range Integer, from
64
- # `InvalidValueGenerator.corrupt`) was routed here as the AGGREGATE
65
- # IDENTITY instead of the domain's own required argument — accepted
66
- # unconditionally, leaving `Mark`'s own `to` fact absent from the
67
- # payload entirely. Ruby refused `AbsentArgument` ("Mark was not
68
- # given to — it takes to"); Rust's stricter `RoutingEnvelope::
69
- # from_json` refuses the malformed scalar itself, TypeMismatch,
70
- # before the domain payload is ever examined — the observed
71
- # divergence. Tightening this branch to Rust's own contract (a
72
- # scalar `to:` must be a `String`) makes both refuse the same way,
73
- # for the same reason, at the same step.
74
- def scalar_envelope(to)
75
- return [to, []] if to.is_a?(String)
76
-
77
- raise TypeMismatch, "to: must be a string aggregate identity or an entity route, got #{Rendering.describe(to)}"
78
- end
79
- private_class_method :scalar_envelope
80
-
81
- # The Hash-shaped half of `to:` — pulled out of `envelope` because
82
- # it is a self-contained parse (raises on an unrecognized key, then
83
- # returns the pair) with no dependency on anything `envelope` does
84
- # afterward; the validations that follow apply the same way
85
- # whichever branch produced `aggregate`/`entities`.
86
- def parse_envelope_hash(to)
87
- hash = to.transform_keys(&:to_sym)
88
- unknown = hash.keys - %i[aggregate entity entities]
89
- raise TypeMismatch, "to: does not recognize #{unknown.sort.join(', ')}" unless unknown.empty?
90
-
91
- [hash[:aggregate], entity_identities(hash)]
92
- end
93
- private_class_method :parse_envelope_hash
94
-
95
- # `with:` is deliberately strict. Compatibility-only calls still pass
96
- # loose keyword arguments through the old addressing gate, but a caller
97
- # choosing the explicit envelope cannot smuggle receiver identity back
98
- # into the payload.
99
- def payload(command, with:, legacy:)
100
- if with && !legacy.empty?
101
- raise TypeMismatch,
102
- "dispatch takes command facts in with:, not both with: and loose keyword arguments"
103
- end
104
-
105
- return legacy unless with
106
- raise TypeMismatch, "with: must be a hash of command facts" unless with.is_a?(Hash)
107
-
108
- offered = with.transform_keys(&:to_sym)
109
- declared = command.attributes.map { |attribute| attribute.name.to_sym }
110
- refuse_unknown_facts!(command, offered, declared)
111
- refuse_absent_facts!(command, offered, declared)
112
- offered
113
- end
114
-
115
- # The two `with:` shape checks `payload` runs in sequence — pulled
116
- # out because each is a self-contained "compute a difference, raise
117
- # if non-empty" rule with no dependency on the other. Order stays
118
- # unknown-before-absent, exactly as inline: `payload` calls them in
119
- # that order, so a fact that is both unknown AND leaves something
120
- # else absent still raises UnknownArgument first, same as before.
121
- def refuse_unknown_facts!(command, offered, declared)
122
- unknown = (offered.keys - declared).sort
123
- return if unknown.empty?
124
-
125
- reading = declared.empty? ? "none" : declared.join(", ")
126
- raise UnknownArgument,
127
- RefusalWording.render("UnknownArgument", "unknown_args",
128
- command: command.hecks_name, unknown: unknown.join(", "),
129
- declared: reading)
130
- end
131
- private_class_method :refuse_unknown_facts!
132
-
133
- def refuse_absent_facts!(command, offered, declared)
134
- absent = command.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } - offered.keys
135
- return if absent.empty?
136
-
137
- reading = declared.empty? ? "none" : declared.join(", ")
138
- raise AbsentArgument,
139
- RefusalWording.render("AbsentArgument", "absent_args",
140
- command: command.hecks_name, absent: absent.sort.join(", "),
141
- declared: reading)
142
- end
143
- private_class_method :refuse_absent_facts!
144
-
145
- # BUG#18 — an entity route naming NO entity at all (`entities: []`,
146
- # or neither `entity:` nor `entities:` present) used to come back
147
- # `[]` here unremarked, which only ever refused downstream for an
148
- # ENTITY command (`envelope`'s own `entities.size != entity_depth`
149
- # check, entity_depth >= 1) — for an AGGREGATE-level command
150
- # (entity_depth 0), `[].size == 0` trivially satisfied that check,
151
- # so the degenerate Hash `{aggregate:, entities: []}` reached the
152
- # command's own validation instead of being refused as malformed
153
- # routing. Rust's `RoutingEnvelope::from_json` never let it get
154
- # that far: the Hash branch refuses "entity route requires at
155
- # least one entity identity" unconditionally, before any
156
- # entity_depth is known. Refusing it here too, at the same point,
157
- # closes the gap without touching the scalar branch (a bare
158
- # aggregate identity, no Hash at all) that every ordinary aggregate
159
- # dispatch already uses instead of this shape (`Facade::Handle#
160
- # dispatch`'s own `to: @id`, `CommandRequest`'s own header —
161
- # "aggregate command: { to: "record-id", ... }" — and, now,
162
- # `Judge#address`).
163
- def entity_identities(hash)
164
- raise TypeMismatch, "to: takes entity: or entities:, not both" if hash.key?(:entities) && hash.key?(:entity)
165
-
166
- identities = hash.key?(:entities) ? Array(hash[:entities]) : Array(hash[:entity])
167
- raise TypeMismatch, "to: entity route requires at least one entity identity" if identities.empty?
168
-
169
- identities
27
+ # @param to [String, Hash, nil] a bare aggregate identity, or a Hash with
28
+ # `aggregate:` and `entity:`/`entities:`; nil for no receiver
29
+ # @param entity_depth [Integer] the number of entity-hop identities the call expects
30
+ # @return [Routing::Envelope, nil] the resolved envelope, or nil when `to` is nil
31
+ # @raise [Runtime::TypeMismatch] if `to` is malformed or its entity count does not
32
+ # match `entity_depth`
33
+ def envelope(to, entity_depth: 0) = Invocation.route(to, entity_depth: entity_depth)
34
+
35
+ # Resolves a command's offered facts into its flat args hash. A thin
36
+ # delegator to `Invocation.facts_for` for any caller still naming it.
37
+ #
38
+ # Loaded by invocation.rb (which requires this file), so `Invocation`
39
+ # is always defined by the time either method runs.
40
+ #
41
+ # @param command [Class] a `Bluebook::Command` subclass (or port-operation class)
42
+ # responding to `hecks_name` and `attributes`
43
+ # @param with [Hash, nil] the command's facts, keyed by attribute name; nil when the
44
+ # caller offers `flat` instead
45
+ # @param flat [Hash] the command's facts as a flat args hash, used when `with` is nil
46
+ # @return [Hash{String, Symbol => Object}] the offered facts by attribute name; an
47
+ # attribute offered as nil is kept as nil
48
+ # @raise [Runtime::TypeMismatch] if both `with` and a non-empty `flat` are given, if
49
+ # `with` is not a Hash, or if `with` names an unknown or omits a required attribute
50
+ def payload(command, with:, flat:)
51
+ facts = Invocation.facts_for(command, with: with, flat: flat)
52
+ Invocation.new(verb: nil, target: nil, facts: facts).to_args
170
53
  end
171
- private_class_method :entity_identities
172
54
  end
173
55
  end
174
56
  end
@@ -3,27 +3,27 @@ require_relative "../value"
3
3
  module Hecks
4
4
  module Runtime
5
5
  class SagaInterpreter
6
- # How a saga decides WHICH CONVERSATION an event belongs to — three
6
+ # How a saga decides which conversation an event belongs to — three
7
7
  # tiers, each one a lesson.
8
8
  module Correlation
9
9
  private
10
10
 
11
- # A DOTTED PATH NAMES THE SCALAR FIELD, rather than asking a value object
11
+ # A dotted path names the scalar field, rather than asking a value object
12
12
  # to stand in for one. `correlates_by :end_to_end` would key a saga on
13
13
  # the whole ExternalTransfer::EndToEndReference — and what a non-scalar
14
- # correlation key even IS is representation-dependent (the object
14
+ # correlation key even is is representation-dependent (the object
15
15
  # itself? its serialised text?). `:"end_to_end.value"` reads the one
16
16
  # field with a single unambiguous rendering.
17
17
  def saga_correlation(process_manager, event)
18
18
  path = process_manager.correlates_by.to_s.split(".")
19
- # A LATER EVENT MAY ALREADY HOLD THE SCALAR. `reference.value` digs a
20
- # value object's field out of a FRESH declaration (TransferRequested's
21
- # `reference` IS a TransferReference) — but a downstream event this
19
+ # A later event may already hold the scalar. `reference.value` digs a
20
+ # value object's field out of a fresh declaration (TransferRequested's
21
+ # `reference` is a TransferReference) — but a downstream event this
22
22
  # same value was smuggled through as a passthrough argument
23
23
  # (AccountDebited's `reference:`, resolved by `dispatch_args` to the
24
24
  # bare correlation string) carries it as a scalar already, with
25
25
  # nothing left to dig. `"xfer-1".respond_to?(:[])` is true — String
26
- # has its OWN `[]` (substring indexing) — so checking for keyed
26
+ # has its own `[]` (substring indexing) — so checking for keyed
27
27
  # lookup explicitly, rather than "responds to `[]` at all", is what
28
28
  # stops the second segment from being read as a symbol index into a
29
29
  # string that has already arrived.
@@ -32,9 +32,9 @@ module Hecks
32
32
  end
33
33
  return value unless value.to_s.empty?
34
34
 
35
- # THE STAMP — `deliver_saga_dispatch` marks its own event before this
35
+ # **The stamp** — `deliver_saga_dispatch` marks its own event before this
36
36
  # saga's next step ever asks, for a leg whose command declares
37
- # NEITHER the correlation field itself nor the emitting aggregate's
37
+ # neither the correlation field itself nor the emitting aggregate's
38
38
  # own reference key (the two tiers above). command_interpreter/
39
39
  # argument_gate.rb names the old payload-only lookup "the weakest
40
40
  # part of the gate" : a correlation key arriving on a command only
@@ -48,34 +48,32 @@ module Hecks
48
48
  stamped = event.correlation && event.correlation[process_manager.correlation_head.to_s]
49
49
  return stamped unless stamped.nil? || stamped.to_s.empty?
50
50
 
51
- # A SELF-REFERENCING LEG carries the correlation forward under its
51
+ # A self-referencing leg carries the correlation forward under its
52
52
  # own emitting record's identity — `event.id`, not a field dug back
53
- # out of the payload. This used to read `event.payload[own_key]`
54
- # (`own_key` the aggregate's own reference-key convention, "wire",
55
- # "transfer"), which only ever held a value because LEGACY dispatch
56
- # left the self-addressing key riding along in the payload
57
- # unfiltered. Routing separated from payload (`to:`/`with:`, the
58
- # facade's own `Handle#run` always uses it) closed exactly that
59
- # leak — correctly, since an addressing key is not a fact the
60
- # payload should carry — which left this tier reading an empty
61
- # Hash for any self-referencing leg with no OTHER declared
62
- # attributes (`OnboardingCase.Clear`, `.Decline` — no `attribute`
63
- # lines at all): the saga silently stopped advancing, forever, for
64
- # exactly the leg this tier exists to correlate.
53
+ # out of the payload, because an addressing key is not a fact the
54
+ # payload should carry: routing is kept separate from payload
55
+ # (`to:`/`with:`, the facade's own `Handle#run` always uses it), so
56
+ # `own_key` (the aggregate's own reference-key convention, "wire",
57
+ # "transfer") never rides along in the payload unfiltered. Digging
58
+ # for it there would read an empty Hash for any self-referencing
59
+ # leg with no other declared attributes (`OnboardingCase.Clear`,
60
+ # `.Decline` — no `attribute` lines at all), silently stopping the
61
+ # saga from advancing, forever, for exactly the leg this tier
62
+ # exists to correlate.
65
63
  #
66
64
  # `event.id` says the identical thing this tier always meant —
67
65
  # "the record that just emitted this event, by its own identity" —
68
66
  # and unlike a payload dig it is populated by the record itself,
69
67
  # not by which dispatch convention the caller happened to use.
70
68
  #
71
- # GATED, still — a manually-dispatched command on a wholly
72
- # UNRELATED aggregate can share an event NAME this process_manager happens to
69
+ # Gated, still — a manually-dispatched command on a wholly
70
+ # unrelated aggregate can share an event name this process_manager happens to
73
71
  # handle (`Drawer.Take` also emits "Taken", the same name a
74
- # SAGA-DISPATCHED leg uses) with nothing this saga should read as
72
+ # saga-dispatched leg uses) with nothing this saga should read as
75
73
  # its own conversation. What makes a leg genuinely
76
74
  # self-referencing — the one fact worth trusting `event.id`
77
- # for — is that `correlates_by`'s own head field IS this event's
78
- # OWN aggregate's declared identity, not merely a same-shaped
75
+ # for — is that `correlates_by`'s own head field is this event's
76
+ # own aggregate's declared identity, not merely a same-shaped
79
77
  # name: `OnboardingCase.identity_heads` really does include
80
78
  # `:reference`, `correlates_by :"reference.value"`'s own head ;
81
79
  # `Drawer.identity_heads` is `[:number]`, nowhere close.