hecks 1.3.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 (355) 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 +5 -4
  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/prism.rb +4 -4
  23. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  24. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  25. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  26. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  27. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  28. data/lib/hecks/adapters/driven.rb +4 -4
  29. data/lib/hecks/adapters/driving/github_webhook.rb +18 -18
  30. data/lib/hecks/behaviors/dsl.rb +2 -2
  31. data/lib/hecks/behaviors/expectations.rb +23 -23
  32. data/lib/hecks/behaviors/ir.rb +1 -1
  33. data/lib/hecks/behaviors/rspec.rb +1 -1
  34. data/lib/hecks/behaviors/runner.rb +2 -2
  35. data/lib/hecks/behaviors.rb +1 -1
  36. data/lib/hecks/bluebook/aggregate.rb +13 -13
  37. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
  38. data/lib/hecks/bluebook/assembly/build.rb +1 -1
  39. data/lib/hecks/bluebook/assembly/contract.rb +39 -16
  40. data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
  41. data/lib/hecks/bluebook/assembly/marks.rb +18 -18
  42. data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
  43. data/lib/hecks/bluebook/assembly.rb +9 -9
  44. data/lib/hecks/bluebook/attribute.rb +9 -9
  45. data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
  46. data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
  47. data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
  48. data/lib/hecks/bluebook/behaviour/command.rb +23 -23
  49. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -7
  50. data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
  51. data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
  52. data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
  53. data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
  54. data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
  55. data/lib/hecks/bluebook/behaviour/query.rb +1 -1
  56. data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
  57. data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
  58. data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
  59. data/lib/hecks/bluebook/capabilities.rb +27 -0
  60. data/lib/hecks/bluebook/chapter.rb +28 -9
  61. data/lib/hecks/bluebook/command.rb +12 -12
  62. data/lib/hecks/bluebook/domain_port.rb +9 -9
  63. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  64. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  65. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
  66. data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
  67. data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
  68. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
  69. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
  70. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  71. data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
  72. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  73. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  74. data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
  75. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  76. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
  77. data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
  78. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  79. data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
  80. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  81. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  82. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
  83. data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
  84. data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
  85. data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
  86. data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
  87. data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
  88. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  89. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  90. data/lib/hecks/bluebook/entity.rb +11 -11
  91. data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
  92. data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
  93. data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
  94. data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
  95. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
  96. data/lib/hecks/bluebook/expression/resolver.rb +60 -62
  97. data/lib/hecks/bluebook/hexagon.rb +1 -1
  98. data/lib/hecks/bluebook/lifecycle.rb +1 -1
  99. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
  100. data/lib/hecks/bluebook/meta_validator/judge.rb +101 -87
  101. data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
  102. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
  103. data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
  104. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
  105. data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
  106. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
  107. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
  108. data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
  109. data/lib/hecks/bluebook/meta_validator.rb +70 -70
  110. data/lib/hecks/bluebook/model_check.rb +170 -84
  111. data/lib/hecks/bluebook/pattern_subset.rb +9 -9
  112. data/lib/hecks/bluebook/policy.rb +15 -13
  113. data/lib/hecks/bluebook/process_manager.rb +14 -14
  114. data/lib/hecks/bluebook/project_register.rb +6 -6
  115. data/lib/hecks/bluebook/query.rb +4 -4
  116. data/lib/hecks/bluebook/read_model.rb +14 -14
  117. data/lib/hecks/bluebook/reference.rb +8 -8
  118. data/lib/hecks/bluebook/smoke_test.rb +19 -19
  119. data/lib/hecks/bluebook/synthesizer.rb +12 -12
  120. data/lib/hecks/bluebook/translation.rb +4 -4
  121. data/lib/hecks/bluebook/value_object.rb +6 -6
  122. data/lib/hecks/bluebook.rb +2 -2
  123. data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
  124. data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
  125. data/lib/hecks/codemod.rb +36 -35
  126. data/lib/hecks/construct.rb +6 -6
  127. data/lib/hecks/corpus.rb +317 -0
  128. data/lib/hecks/deprecation.rb +95 -0
  129. data/lib/hecks/doc/reference.rb +19 -19
  130. data/lib/hecks/embryonaut_bluebook.rb +11 -11
  131. data/lib/hecks/facade/cli_door.rb +69 -10
  132. data/lib/hecks/facade/cli_runner.rb +105 -24
  133. data/lib/hecks/facade/command_request.rb +23 -0
  134. data/lib/hecks/facade/handle.rb +79 -32
  135. data/lib/hecks/facade/json_door.rb +106 -25
  136. data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
  137. data/lib/hecks/facade/surface/chapter.rb +26 -17
  138. data/lib/hecks/facade/surface.rb +16 -3
  139. data/lib/hecks/facade.rb +15 -4
  140. data/lib/hecks/forms/app.rb +46 -30
  141. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  142. data/lib/hecks/forms/field_renderer.rb +142 -6
  143. data/lib/hecks/forms/field_shape.rb +180 -19
  144. data/lib/hecks/forms/html.rb +51 -7
  145. data/lib/hecks/forms/index_renderer.rb +14 -2
  146. data/lib/hecks/forms/params.rb +120 -23
  147. data/lib/hecks/forms/port_argument.rb +2 -2
  148. data/lib/hecks/forms/query_form_renderer.rb +2 -2
  149. data/lib/hecks/forms/record_renderer.rb +2 -2
  150. data/lib/hecks/forms/record_table.rb +1 -1
  151. data/lib/hecks/forms/value_object_shape.rb +3 -3
  152. data/lib/hecks/forms.rb +24 -4
  153. data/lib/hecks/fqn.rb +1 -1
  154. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  155. data/lib/hecks/framework.rb +48 -17
  156. data/lib/hecks/freezer.rb +11 -11
  157. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
  158. data/lib/hecks/fuzzing/combination_miner.rb +119 -0
  159. data/lib/hecks/fuzzing/concurrent_dispatch.rb +140 -45
  160. data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
  161. data/lib/hecks/fuzzing/differential.rb +158 -0
  162. data/lib/hecks/fuzzing/domain_generator.rb +694 -0
  163. data/lib/hecks/fuzzing/era_boundary.rb +37 -18
  164. data/lib/hecks/fuzzing/form_census.rb +35 -20
  165. data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
  166. data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
  167. data/lib/hecks/fuzzing/isolated_boot.rb +62 -62
  168. data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
  169. data/lib/hecks/fuzzing/persistence_parity.rb +19 -21
  170. data/lib/hecks/fuzzing/properties/corrections.rb +9 -9
  171. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +154 -49
  172. data/lib/hecks/fuzzing/properties/guards.rb +42 -42
  173. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
  174. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +22 -35
  175. data/lib/hecks/fuzzing/properties/outbox.rb +24 -24
  176. data/lib/hecks/fuzzing/properties/querying.rb +17 -17
  177. data/lib/hecks/fuzzing/properties.rb +64 -40
  178. data/lib/hecks/fuzzing/qa_settings.rb +152 -0
  179. data/lib/hecks/fuzzing/replay.rb +114 -114
  180. data/lib/hecks/fuzzing/rotation_priority.rb +10 -10
  181. data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
  182. data/lib/hecks/fuzzing/self_consistency.rb +141 -112
  183. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +35 -35
  184. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  185. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  186. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  187. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +58 -31
  188. data/lib/hecks/fuzzing/sequence_generator.rb +111 -23
  189. data/lib/hecks/fuzzing/shrinker.rb +197 -0
  190. data/lib/hecks/fuzzing/structural_skips.rb +23 -130
  191. data/lib/hecks/fuzzing/sweep_depth.rb +4 -4
  192. data/lib/hecks/fuzzing/target_capabilities.rb +49 -18
  193. data/lib/hecks/fuzzing/value_generator.rb +14 -14
  194. data/lib/hecks/fuzzing.rb +5 -0
  195. data/lib/hecks/grammar/evolve.rb +10 -10
  196. data/lib/hecks/grammar.rb +7 -7
  197. data/lib/hecks/ir.rb +13 -13
  198. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  199. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  200. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  201. data/lib/hecks/language/oidc.json +5 -0
  202. data/lib/hecks/literal.rb +9 -9
  203. data/lib/hecks/naming.rb +25 -25
  204. data/lib/hecks/ports/access_control.rb +58 -2
  205. data/lib/hecks/ports/agent/answers.rb +83 -6
  206. data/lib/hecks/ports/agent.rb +119 -35
  207. data/lib/hecks/ports/authentication.rb +44 -4
  208. data/lib/hecks/ports/authorization.rb +53 -11
  209. data/lib/hecks/ports/clock.rb +42 -23
  210. data/lib/hecks/ports/extraction.rb +16 -0
  211. data/lib/hecks/ports/identity_assignment.rb +24 -2
  212. data/lib/hecks/ports/identity_generation.rb +17 -3
  213. data/lib/hecks/ports/identity_resolution.rb +18 -1
  214. data/lib/hecks/ports/loading.rb +4 -0
  215. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  216. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  217. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  218. data/lib/hecks/ports/persistence/execution.rb +4 -0
  219. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  220. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  221. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  222. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  223. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  224. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  225. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  226. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  227. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  228. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  229. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  230. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  231. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  241. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  242. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  243. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  244. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
  245. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  246. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  247. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  248. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
  249. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
  250. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
  251. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  252. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  253. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  254. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  255. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  256. data/lib/hecks/ports/persistence.rb +36 -1
  257. data/lib/hecks/ports/projection.rb +61 -7
  258. data/lib/hecks/ports/query/in_memory.rb +3 -3
  259. data/lib/hecks/ports/query/ordering.rb +6 -6
  260. data/lib/hecks/ports/query.rb +35 -0
  261. data/lib/hecks/projections/bootstrap_table.rb +112 -0
  262. data/lib/hecks/projections/diagrams.rb +75 -75
  263. data/lib/hecks/projections/glossary/html.rb +3 -3
  264. data/lib/hecks/projections/glossary/markdown.rb +3 -3
  265. data/lib/hecks/projections/glossary/mermaid.rb +1 -1
  266. data/lib/hecks/projections/glossary/sections.rb +1 -1
  267. data/lib/hecks/projections/glossary/sentences.rb +5 -5
  268. data/lib/hecks/projections/glossary.rb +15 -15
  269. data/lib/hecks/projections/ir.rb +1 -1
  270. data/lib/hecks/projections/model/deviations.rb +18 -17
  271. data/lib/hecks/projections/model.rb +25 -21
  272. data/lib/hecks/projections/oidc.rb +7 -7
  273. data/lib/hecks/projections/parser_table.rb +5 -5
  274. data/lib/hecks/projections/reference.rb +3 -3
  275. data/lib/hecks/projections/rust_vocabulary.rb +443 -0
  276. data/lib/hecks/projections/shape.rb +2 -2
  277. data/lib/hecks/projections/statements.rb +11 -11
  278. data/lib/hecks/projections/vocabulary.rb +9 -9
  279. data/lib/hecks/projections.rb +5 -3
  280. data/lib/hecks/projector/cli_projector.rb +29 -29
  281. data/lib/hecks/projector/docs_projector.rb +13 -13
  282. data/lib/hecks/projector/exporter.rb +42 -21
  283. data/lib/hecks/projector/ir_projector.rb +1 -1
  284. data/lib/hecks/projector/narrate_projector.rb +11 -11
  285. data/lib/hecks/projector/target.rb +13 -13
  286. data/lib/hecks/projector.rb +15 -15
  287. data/lib/hecks/query_ir.rb +47 -47
  288. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  289. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  290. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  291. data/lib/hecks/query_specification/common/null_policy.rb +57 -13
  292. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  293. data/lib/hecks/query_specification/common/options.rb +25 -0
  294. data/lib/hecks/query_specification/field_path.rb +69 -15
  295. data/lib/hecks/query_specification/hop_path.rb +57 -20
  296. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  297. data/lib/hecks/rendering.rb +3 -3
  298. data/lib/hecks/router/namespace_installer.rb +3 -3
  299. data/lib/hecks/router.rb +1 -1
  300. data/lib/hecks/runtime/aggregate_lock.rb +11 -11
  301. data/lib/hecks/runtime/boot_gates.rb +3 -3
  302. data/lib/hecks/runtime/caller.rb +8 -8
  303. data/lib/hecks/runtime/capability_graph.rb +2 -2
  304. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
  305. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +42 -63
  306. data/lib/hecks/runtime/command_interpreter.rb +159 -102
  307. data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
  308. data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
  309. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  310. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  311. data/lib/hecks/runtime/command_rules/references.rb +35 -35
  312. data/lib/hecks/runtime/command_rules.rb +3 -0
  313. data/lib/hecks/runtime/dependency_planning.rb +11 -11
  314. data/lib/hecks/runtime/dispatcher.rb +283 -90
  315. data/lib/hecks/runtime/entity_element.rb +151 -67
  316. data/lib/hecks/runtime/entity_interpreter.rb +108 -82
  317. data/lib/hecks/runtime/errors.rb +19 -19
  318. data/lib/hecks/runtime/event.rb +6 -6
  319. data/lib/hecks/runtime/identity.rb +22 -22
  320. data/lib/hecks/runtime/instance.rb +39 -14
  321. data/lib/hecks/runtime/interpreting.rb +12 -12
  322. data/lib/hecks/runtime/invocation.rb +276 -0
  323. data/lib/hecks/runtime/loader.rb +14 -14
  324. data/lib/hecks/runtime/outbox.rb +23 -23
  325. data/lib/hecks/runtime/policy_interpreter.rb +54 -54
  326. data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
  327. data/lib/hecks/runtime/query_interpreter.rb +55 -64
  328. data/lib/hecks/runtime/reaction_invocation.rb +20 -20
  329. data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
  330. data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
  331. data/lib/hecks/runtime/reference_hop.rb +6 -6
  332. data/lib/hecks/runtime/refusal_wording.rb +92 -115
  333. data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
  334. data/lib/hecks/runtime/registry/verification.rb +36 -26
  335. data/lib/hecks/runtime/registry.rb +45 -28
  336. data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
  337. data/lib/hecks/runtime/routing.rb +10 -153
  338. data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
  339. data/lib/hecks/runtime/saga_interpreter.rb +64 -64
  340. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  341. data/lib/hecks/runtime/tenant_check.rb +9 -9
  342. data/lib/hecks/runtime/tenant_scope.rb +5 -5
  343. data/lib/hecks/runtime/value/admission.rb +75 -30
  344. data/lib/hecks/runtime/value/coercion.rb +318 -127
  345. data/lib/hecks/runtime/value/entity_list_coercion.rb +34 -34
  346. data/lib/hecks/runtime/value.rb +21 -21
  347. data/lib/hecks/runtime.rb +7 -7
  348. data/lib/hecks/storehouse.rb +64 -64
  349. data/lib/hecks/version.rb +3 -3
  350. data/lib/hecks/vocabulary.rb +205 -4
  351. data/lib/hecks.rb +13 -11
  352. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
  353. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
  354. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
  355. metadata +22 -2
@@ -3,9 +3,9 @@ require_relative "value"
3
3
 
4
4
  module Hecks
5
5
  module Runtime
6
- # THE SCALAR AN IDENTITY PATH NAMES.
6
+ # **The scalar an identity path names**.
7
7
  #
8
- # An identity is DECLARED as a path — `identified_by :number` — and
8
+ # An identity is declared as a path — `identified_by :number` — and
9
9
  # this is the one place that reads one. It follows the path and nothing else.
10
10
  #
11
11
  # What it replaced was `Value.identifier`, which opened a one-field value
@@ -24,7 +24,7 @@ module Hecks
24
24
  module_function
25
25
 
26
26
  # A hash read that decides which spelling of a key answers by
27
- # PRESENCE, never by `||` — a bare `||` treats a genuinely-held
27
+ # presence, never by `||` — a bare `||` treats a genuinely-held
28
28
  # `false` the same as an absent key and falls through to the other
29
29
  # spelling, landing on `nil` instead of the real, stored answer.
30
30
  def hash_lookup(hash, key)
@@ -32,7 +32,7 @@ module Hecks
32
32
  hash.key?(sym) ? hash[sym] : hash[key]
33
33
  end
34
34
 
35
- # The head names the ATTRIBUTE and is consumed by whoever looked the value
35
+ # The head names the attribute and is consumed by whoever looked the value
36
36
  # up; what is left is the walk down into it. A path with no fields to walk
37
37
  # — an aggregate that declares no identity and falls back to `id` — hands
38
38
  # back what it was given, because there is nothing declared to dig for.
@@ -45,17 +45,17 @@ module Hecks
45
45
  end
46
46
  end
47
47
 
48
- # THE IDENTITY IS THE JOIN OF ITS PARTS, in declaration order. Shared by
48
+ # The identity is the join of its parts, in declaration order. Shared by
49
49
  # `CommandInterpreter` (an aggregate acting on itself) and
50
50
  # `EntityInterpreter` (a piece addressed through its aggregate) — a piece
51
51
  # declares an identity the same shape a head does, so it derives one the
52
52
  # same way. `construct` answers `identity_paths` / `identity_heads` /
53
53
  # `attribute` (an Aggregate or an Entity, either one) ; `value_owner`
54
54
  # answers for coercion (`Value.for_attribute`'s first argument), which for
55
- # an entity is its OWNING aggregate — an entity's value objects resolve
55
+ # an entity is its owning aggregate — an entity's value objects resolve
56
56
  # through the aggregate's namespace, not its own.
57
57
  #
58
- # A part the payload does not carry makes the WHOLE identity unresolvable,
58
+ # A part the payload does not carry makes the whole identity unresolvable,
59
59
  # rather than half of one. Half an identity names nothing, and joining what
60
60
  # did arrive would silently name a different record on every dispatch — the
61
61
  # precise failure that minting an id caused, arrived at by another road.
@@ -64,10 +64,10 @@ module Hecks
64
64
  return nil if paths.empty?
65
65
 
66
66
  parts = paths.map { |path| from(construct, args, path, value_owner: value_owner) }
67
- # A BLANK PART NAMES NOTHING, the same as an ABSENT one — AN ID IS A
68
- # SCALAR, and "" is not a fact about anything. This used to check only
67
+ # A blank part names nothing, the same as an absent one — an ID is a
68
+ # scalar, and "" is not a fact about anything. This used to check only
69
69
  # `nil?`, so a canonical text extracted as "" (an expression whose
70
- # source did not survive extraction) resolved to a REAL, empty-string
70
+ # source did not survive extraction) resolved to a real, empty-string
71
71
  # identity — a record addressable by an id no caller could have meant.
72
72
  return nil if parts.any? { |part| part.nil? || (part.respond_to?(:empty?) && part.empty?) }
73
73
 
@@ -75,7 +75,7 @@ module Hecks
75
75
  end
76
76
 
77
77
  # A path digs into the value object that carries the identity, so what is
78
- # stored is the SCALAR inside it rather than the object serialised whole.
78
+ # stored is the scalar inside it rather than the object serialised whole.
79
79
  def from(construct, args, key, value_owner: construct)
80
80
  return nil unless key
81
81
 
@@ -86,7 +86,7 @@ module Hecks
86
86
  unless rest.empty?
87
87
  held = args[head]
88
88
  held = held.to_h if held.respond_to?(:to_h)
89
- # AN ID IS ALWAYS A SCALAR. The path says WHICH FIELD carries it, so a
89
+ # **An ID is always a scalar**. The path says which field carries it, so a
90
90
  # caller may hand that field's value straight over — a string or a
91
91
  # number, never a serialised object. Only a value object that actually
92
92
  # arrived whole has to be opened.
@@ -95,18 +95,18 @@ module Hecks
95
95
  return rest.reduce(held) { |h, f| h.is_a?(Hash) ? hash_lookup(h, f) : nil }&.to_s
96
96
  end
97
97
 
98
- # Coerced against the identity ATTRIBUTE only when the caller actually
98
+ # Coerced against the identity attribute only when the caller actually
99
99
  # named it. A saga addresses an aggregate by its correlation key, and
100
- # that key carries the id ALREADY RESOLVED — coercing "w1" against a
100
+ # that key carries the id already resolved — coercing "w1" against a
101
101
  # WireReference asked the caller to pass fields for a value object they
102
102
  # never mentioned.
103
103
  attribute = construct.identity_heads.include?(head) ? construct.attribute(head) : nil
104
104
  raw = args[head]
105
105
  return raw unless attribute
106
106
 
107
- # AN ID IS ALWAYS A SCALAR — same contract the dotted branch above
108
- # already keeps, just reached a different way here: a BARE
109
- # (undotted) identity path names one of THIS construct's own
107
+ # **An ID is always a scalar** — same contract the dotted branch above
108
+ # already keeps, just reached a different way here: a bare
109
+ # (undotted) identity path names one of this construct's own
110
110
  # declared attributes directly, and when that attribute's type is
111
111
  # a value object (Translation's own compound `identified_by
112
112
  # :domain, :from, :to`, each typed `TranslationDomainName`/
@@ -118,23 +118,23 @@ module Hecks
118
118
  # memory address (`#<Hecks::Runtime::Value:0x...>`) into
119
119
  # every refusal quoting this identity — found live via bin/fuzz on
120
120
  # the self-hosted "translation" domain (replay_is_deterministic:
121
- # the SAME address never repeats, so two replays of the
121
+ # the same address never repeats, so two replays of the
122
122
  # identical steps produced different histories the moment a
123
123
  # Translation went missing). `materialize_unwrapped` is the
124
- # SAME single-field-VO-recurses-to-its-bare-scalar helper
124
+ # same single-field-VO-recurses-to-its-bare-scalar helper
125
125
  # `read_model_interpreter.rb` already uses for exactly this
126
126
  # unwrap; passthrough for anything that isn't a Value at all.
127
127
  Value.materialize_unwrapped(Value.for_attribute(value_owner, attribute, raw)).to_s
128
128
  end
129
129
 
130
- # How an identity READS when the runtime has to name it in a refusal — the
130
+ # How an identity reads when the runtime has to name it in a refusal — the
131
131
  # paths as they were declared, so the message quotes the bluebook back.
132
132
  def reading(construct)
133
133
  construct.identity_paths.join(", ")
134
134
  end
135
135
 
136
- # BEST-EFFORT, FOR A LOCK KEY ONLY — `Runtime::AggregateLock`'s own
137
- # per-record striping needs SOME id to key on before dispatch has run
136
+ # Best-effort, for a lock key only — `Runtime::AggregateLock`'s own
137
+ # per-record striping needs some id to key on before dispatch has run
138
138
  # far enough to hydrate for real, so this walks the identical chain
139
139
  # `CommandInterpreter#hydrate_existing`/`#hydrate_prior_or_initial`
140
140
  # and `EntityInterpreter#parent` already use to locate the real
@@ -1,5 +1,6 @@
1
1
  require_relative "value"
2
2
  require_relative "identity"
3
+ require_relative "../ports/persistence/codec_boundary"
3
4
 
4
5
  module Hecks
5
6
  module Runtime
@@ -12,7 +13,7 @@ module Hecks
12
13
  class Instance
13
14
  attr_reader :aggregate, :id
14
15
  attr_accessor :state
15
- # OUT-OF-BAND ADAPTER BOOKKEEPING, NOT DOMAIN STATE — the optimistic-
16
+ # **Out-of-band adapter bookkeeping, not domain state** — the optimistic-
16
17
  # concurrency version a CAS-capable adapter (Postgres today) stamps
17
18
  # on a record it reads/writes, so a later `save` can assert "commit
18
19
  # only if nobody has written since". Deliberately absent from
@@ -24,16 +25,40 @@ module Hecks
24
25
  # ADR) for the full mechanism.
25
26
  attr_accessor :version
26
27
 
27
- # `args:` — THE ORIGINAL COMMAND PAYLOAD, offered only by a fresh
28
+ # `args:` — the original command payload, offered only by a fresh
28
29
  # creation (`CommandInterpreter#hydrate_legacy_creation`/
29
30
  # `#hydrate_complete_state`/`#hydrate_prior_or_initial`, each already
30
31
  # holding it when they mint a brand-new record). See
31
32
  # `materialize_identity!` for why a composite identity needs it.
32
- def initialize(aggregate:, id:, state: nil, args: nil)
33
+ #
34
+ # `hydrate:` — ON BY DEFAULT, and every existing caller keeps getting
35
+ # exactly what it always got: `state` re-walked through
36
+ # `hydrate_with_defaults` (declared defaults filled, every attribute
37
+ # re-coerced through `Value.for_attribute`, an entity list's every
38
+ # element rebuilt and re-validated). `false` is for exactly one
39
+ # caller (`Adapters::Memory#build_instance`, judge-bootstrapping
40
+ # only — see its own header) that already knows `state` needs none
41
+ # of that: it is a shallow dup of an ALREADY-hydrated, ALREADY-
42
+ # validated live `Instance`'s own state, not a raw value pulled off
43
+ # a wire. Skipping the re-walk is what turns a `list_of` entity's Nth
44
+ # save from O(N) (re-hydrating every element saved so far, for every
45
+ # save) into O(1) — the quadratic cost `Adapters::Memory`'s own
46
+ # header traces start to finish. `CodecBoundary.check_state!` still
47
+ # runs either way ; only the re-hydration is skipped.
48
+ def initialize(aggregate:, id:, state: nil, args: nil, hydrate: true)
33
49
  @aggregate = aggregate
34
50
  @id = id
35
- @state = state ? self.class.hydrate_with_defaults(aggregate, state) : self.class.defaults(aggregate)
36
- @version = nil
51
+ # Inside a persistence adapter call this refuses undecoded stored
52
+ # state (Ports::Persistence::CodecBoundary); everywhere else, no-op.
53
+ Ports::Persistence::CodecBoundary.check_state!(aggregate, state) if state
54
+ @state = if !hydrate
55
+ state || self.class.defaults(aggregate)
56
+ elsif state
57
+ self.class.hydrate_with_defaults(aggregate, state)
58
+ else
59
+ self.class.defaults(aggregate)
60
+ end
61
+ @version = nil
37
62
  materialize_identity!(args)
38
63
  end
39
64
 
@@ -53,7 +78,7 @@ module Hecks
53
78
 
54
79
  def self.defaults(aggregate)
55
80
  state = aggregate.attributes.to_h do |attr|
56
- # FROZEN, like a list that has had something appended to it.
81
+ # Frozen, like a list that has had something appended to it.
57
82
  # An untouched list is the easiest one to miss and the easiest
58
83
  # to mutate: nothing has replaced it yet, so a caller pushing
59
84
  # into it writes straight into the aggregate's own state.
@@ -93,7 +118,7 @@ module Hecks
93
118
  @state.key?(name) || super
94
119
  end
95
120
 
96
- # `id: @id` LAST, not first — see Facade::Handle#to_h's own comment
121
+ # `id: @id` last, not first — see Facade::Handle#to_h's own comment
97
122
  # for the full story (the same fix, landed there first): an
98
123
  # aggregate free to declare its own attribute literally named `id`
99
124
  # (BurningManPrep's `Item`, `attribute :id, ItemId`) has that
@@ -102,15 +127,15 @@ module Hecks
102
127
  # the correct bare identity. `@id` merged last always wins.
103
128
  def to_h = @state.merge(id: @id)
104
129
 
105
- # A COPY A MUTATION MAY TOUCH. Every adapter but Memory hands `find`
130
+ # A copy a mutation may touch. Every adapter but Memory hands `find`
106
131
  # a freshly-decoded Instance already; Memory's holds the record it
107
- # eventually saves — the SAME state Hash, aliased. Before `ensures`
132
+ # eventually saves — the same state Hash, aliased. Before `ensures`
108
133
  # existed, nothing could refuse between apply_mutations and save, so
109
134
  # that aliasing was invisible: a dispatch either ran to completion or
110
135
  # raised before touching state at all. `ensures` is the first refusal
111
- # to sit AFTER mutation, and it found the bug the moment it did — an
136
+ # to sit after mutation, and it found the bug the moment it did — an
112
137
  # in-memory record left half-mutated by a dispatch that then refused.
113
- # `command_interpreter`/`entity_interpreter` hydrate an EXISTING
138
+ # `command_interpreter`/`entity_interpreter` hydrate an existing
114
139
  # record through this, never through the adapter's own return value
115
140
  # directly, so a refused ensures leaves the stored record untouched
116
141
  # regardless of which adapter is holding it.
@@ -127,13 +152,13 @@ module Hecks
127
152
 
128
153
  private
129
154
 
130
- # M17 — a COMPOSITE identity (`identity_heads.size > 1`, e.g.
155
+ # M17 — a composite identity (`identity_heads.size > 1`, e.g.
131
156
  # `identified_by :branch_code, :box_number`) has no single
132
157
  # `identified_by` to fall back to `:id` for — `@aggregate.identified_by`
133
158
  # is nil the moment there is more than one head (`Behaviour::Identified
134
159
  # #derive_identity`), so the single-head branch below never runs for
135
160
  # it at all. A creating command that declares those heads as ordinary
136
- # attributes but doesn't ALSO `sets` them (redundant with the identity
161
+ # attributes but doesn't also `sets` them (redundant with the identity
137
162
  # the command's own args already named) used to persist every head as
138
163
  # nil — the id correctly named the record, but the record's own
139
164
  # attributes forgot what named it.
@@ -142,7 +167,7 @@ module Hecks
142
167
  # same reason the single-head branch below won't guess a multi-path
143
168
  # identifier from its joined string: `@id` is a display key, not a
144
169
  # reversible serialization, and a composite's own separator can
145
- # collide with a part's own text. `args` is only offered by a FRESH
170
+ # collide with a part's own text. `args` is only offered by a fresh
146
171
  # creation (`Instance.new`'s own `args:` comment); an existing record
147
172
  # read back from storage has no args to lean on, and doesn't need
148
173
  # one since a correctly-persisted record already carries its own
@@ -19,7 +19,7 @@ module Hecks
19
19
 
20
20
  private
21
21
 
22
- # Logged AFTER the step's own work, so a step that wraps sub-steps logs
22
+ # Logged after the step's own work, so a step that wraps sub-steps logs
23
23
  # itself once everything inside it has already logged — trace order is
24
24
  # completion order, which is dispatch order.
25
25
  def step(name)
@@ -28,21 +28,21 @@ module Hecks
28
28
  result
29
29
  end
30
30
 
31
- # DRIVES `DISPATCH_ORDER` (CommandInterpreter/EntityInterpreter, each
32
- # mirroring its own Vocabulary::*DispatchOrder — vocabulary.bluebook,
33
- # held equal to it by spec/vocabulary_conformance_spec.rb) by `send`ing
31
+ # Drives `DISPATCH_ORDER` (CommandInterpreter/EntityInterpreter, each
32
+ # read off its own generated Vocabulary::*DispatchOrder table
33
+ # vocabulary.bluebook, via lib/hecks/vocabulary.rb) by `send`ing
34
34
  # each declared step name against the including interpreter's own
35
35
  # `step_<name>` handler, in declared order. What used to be `call`'s own
36
- # literal sequence of method calls is now DATA driving that sequence —
36
+ # literal sequence of method calls is now data driving that sequence —
37
37
  # tracing a real dispatch and comparing it to the declaration is
38
38
  # tautological once `call` mechanically follows the declaration; a
39
39
  # conditional step (assign_creation_attributes, advance_lifecycle) still
40
- # has to guard ITSELF at the top of its own handler and skip tracing
40
+ # has to guard itself at the top of its own handler and skip tracing
41
41
  # when it does not fire, rather than the caller branching around it —
42
42
  # see CommandInterpreter#step_assign_creation_attributes.
43
- # THE COMMIT BOUNDARY. Every step up to `save` runs as before;
43
+ # The commit boundary. Every step up to `save` runs as before;
44
44
  # `save`, `emit`, and the outbox enqueue that follows them run
45
- # inside ONE `repository.transaction` — so the aggregate row, its
45
+ # inside one `repository.transaction` — so the aggregate row, its
46
46
  # journal entry, the recorded event, and the outbox rows naming
47
47
  # who is owed a reaction commit together or not at all. No new
48
48
  # step is added to the vocabulary's dispatch order (the step list
@@ -71,20 +71,20 @@ module Hecks
71
71
  ctx.outbox_rows = @registry.outbox.enqueue(ctx.repository, Array(ctx.result), ctx.domain)
72
72
  end
73
73
 
74
- # THE CONCURRENCY-CONTROL SPLIT — see docs/decisions/ (concurrency
74
+ # The concurrency-control split — see docs/decisions/ (concurrency
75
75
  # control ADR) for the full mechanism. A repository that declares
76
76
  # `:optimistic_concurrency` (Postgres today) already closes the
77
77
  # lost-update gap itself, via `step_save`'s CAS + `#call`'s own
78
78
  # `StaleWrite` retry loop — an extra in-process lock here would be
79
79
  # pointless overhead, not incorrect, so it's skipped for clarity.
80
80
  # A repository that declares `:cross_process_lock` (PostgresEra —
81
- # ADR 0036) holds a REAL Postgres advisory lock for the whole
81
+ # ADR 0036) holds a real Postgres advisory lock for the whole
82
82
  # dispatch order instead: unlike Heki/Memory (confirmed
83
83
  # process-local, never a second process writing the same store),
84
84
  # PostgresEra's own tables can be dispatched against concurrently
85
85
  # by `rust/host` from a separate OS process, and an in-process
86
- # `Mutex` is invisible to that. Every OTHER repository gets the
87
- # striped `Mutex` below, held for the WHOLE dispatch-order run, so
86
+ # `Mutex` is invisible to that. Every other repository gets the
87
+ # striped `Mutex` below, held for the whole dispatch-order run, so
88
88
  # a second thread's own hydrate can't start until the first
89
89
  # thread's save has landed. `lock_key_id` is best-effort
90
90
  # (`Identity.best_effort`) — `nil` still locks correctly, just
@@ -0,0 +1,276 @@
1
+ require_relative "errors"
2
+ require_relative "refusal_wording"
3
+ require_relative "routing"
4
+ require_relative "../rendering"
5
+
6
+ module Hecks
7
+ module Runtime
8
+ # **One dispatch, as data** — the verb, the receiver it is addressed to
9
+ # (`target`, a `Routing::Envelope` or nil), and every fact the caller did
10
+ # or did not offer. Built once per dispatch by `Invocation.from_call`, the
11
+ # only place the runtime interprets the shape of a call (`to:` vs `with:`
12
+ # vs loose keyword arguments, a port operation's reference attribute
13
+ # lifted into `to:`). `Runtime::Routing.envelope`/`.payload` delegate here
14
+ # and no longer hold that logic themselves.
15
+ #
16
+ # `facts` maps a fact name to exactly one of:
17
+ #
18
+ # - `Invocation::Absent` — the key was never offered (every declared
19
+ # attribute the caller left out appears so)
20
+ # - `Invocation::Null` — the key was offered with an explicit nil
21
+ # - `Invocation::Present` — the key was offered with a value
22
+ #
23
+ # Keys are kept exactly as offered: `with:` keys are symbolized (as they
24
+ # always were), loose keyword arguments keep whatever key the caller
25
+ # used. Undeclared keys a caller offered are kept too — refusing them is
26
+ # still `refuse_unknown_arguments`' job, a dispatch step, not this one.
27
+ #
28
+ # PR I1 changes no behavior: every interpreter still reads `ctx.args`,
29
+ # which is `#to_args` — the same Hash `Routing.payload` used to return.
30
+ # Roadmap I2 moves the Ruby `decode_arguments` step onto `facts` itself.
31
+ Invocation = Data.define(:verb, :target, :facts)
32
+
33
+ # The fact markers, the fact readers, and `from_call` — see above.
34
+ class Invocation
35
+ # A fact the caller offered with a real (non-nil) value.
36
+ Present = Data.define(:value) do
37
+ def inspect = "#<Invocation::Present #{value.inspect}>"
38
+ alias_method :to_s, :inspect
39
+ end
40
+
41
+ # The class of the two frozen marker singletons below — never
42
+ # instantiated anywhere else.
43
+ class Marker
44
+ def initialize(name)
45
+ @name = name
46
+ freeze
47
+ end
48
+
49
+ def inspect = "Invocation::#{@name}"
50
+ alias to_s inspect
51
+ end
52
+ private_constant :Marker
53
+
54
+ Absent = Marker.new("Absent")
55
+ Null = Marker.new("Null")
56
+
57
+ def initialize(verb:, target:, facts:)
58
+ super(verb: verb, target: target, facts: facts.dup.freeze)
59
+ end
60
+
61
+ # The fact recorded under `name` — `Absent` for a name never offered
62
+ # and never declared either.
63
+ def fact(name) = facts.fetch(name, Absent)
64
+
65
+ def present?(name) = fact(name).is_a?(Present)
66
+ def null?(name) = fact(name).equal?(Null)
67
+ def absent?(name) = fact(name).equal?(Absent)
68
+
69
+ # A Present fact's value; nil for an explicit Null. Raises KeyError for
70
+ # an Absent fact — "never offered" has no value, and answering nil
71
+ # would re-conflate it with an explicit null, the very ambiguity this
72
+ # type exists to remove. Ask `absent?`/`present?` first.
73
+ def value(name)
74
+ case (found = fact(name))
75
+ when Present then found.value
76
+ when Null then nil
77
+ else raise KeyError, "#{verb} was not given #{name.inspect}"
78
+ end
79
+ end
80
+
81
+ # The legacy args hash, byte for byte what `Routing.payload` returned
82
+ # before this type existed: offered keys in offered order, Absent keys
83
+ # omitted, Null keys mapped to nil. A fresh Hash every call.
84
+ def to_args
85
+ facts.each_with_object({}) do |(name, found), args|
86
+ next if found.equal?(Absent)
87
+
88
+ args[name] = found.equal?(Null) ? nil : found.value
89
+ end
90
+ end
91
+
92
+ class << self
93
+ # **The one reading of a call's shape**. `receiver:` picks which of the
94
+ # three dispatch shapes this is, because each has always checked its
95
+ # parts in its own order and a malformed call's refusal depends on
96
+ # that order:
97
+ #
98
+ # :aggregate — facts first (`with:` checks), then `to:`
99
+ # :entity — `to:` first, then the block (which resolves the
100
+ # entity chain, UnknownVerb), then facts
101
+ # :port — a reference attribute / identity field lifted into
102
+ # `to:`, then `to:` (which a port operation requires),
103
+ # then facts
104
+ #
105
+ # The block answers the declaring command or port operation (anything
106
+ # with `hecks_name` and `attributes`); it is called exactly once, at
107
+ # the point in that order where the caller always resolved it.
108
+ # `aggregate:` is the owning aggregate construct, read for `:port`
109
+ # only.
110
+ def from_call(verb, to:, with:, legacy:, receiver: :aggregate, entity_depth: 0, aggregate: nil, &declaring)
111
+ case receiver
112
+ when :aggregate
113
+ command = declaring.call
114
+ facts = facts_for(command, with: with, legacy: legacy)
115
+ new(verb: verb, target: route(to), facts: facts)
116
+ when :entity
117
+ target = route(to, entity_depth: entity_depth)
118
+ command = declaring.call
119
+ new(verb: verb, target: target, facts: facts_for(command, with: with, legacy: legacy))
120
+ when :port
121
+ port_call(verb, aggregate, declaring.call, to: to, with: with, legacy: legacy)
122
+ else
123
+ raise ArgumentError, "unknown receiver #{receiver.inspect}"
124
+ end
125
+ end
126
+
127
+ # `to:` as a `Routing::Envelope`, or nil when no `to:` was given.
128
+ def route(to, entity_depth: 0)
129
+ return nil if to.nil?
130
+
131
+ aggregate, entities = to.is_a?(Hash) ? envelope_hash(to) : scalar_envelope(to)
132
+
133
+ raise TypeMismatch, "to: must name the receiving aggregate identity" if aggregate.nil? || aggregate.to_s.empty?
134
+ if entities.size != entity_depth
135
+ raise TypeMismatch,
136
+ "to: for an entity command needs #{entity_depth} entity " \
137
+ "#{entity_depth == 1 ? 'identity' : 'identities'} after the aggregate — got #{entities.size}"
138
+ end
139
+ raise TypeMismatch, "to: contains a blank entity identity" if entities.any? do |identity|
140
+ identity.nil? || identity.to_s.empty?
141
+ end
142
+
143
+ Routing::Envelope.new(aggregate: aggregate, entities: entities)
144
+ end
145
+
146
+ # The offered facts for `declaring`, as Absent/Null/Present — offered
147
+ # keys first, in offered order, then every declared attribute that was
148
+ # not offered, as Absent.
149
+ def facts_for(declaring, with:, legacy:)
150
+ offered = offered_facts(declaring, with: with, legacy: legacy)
151
+ facts = offered.each_with_object({}) do |(name, value), found|
152
+ found[name] = value.nil? ? Null : Present.new(value: value)
153
+ end
154
+ declaring.attributes.each do |attribute|
155
+ name = attribute.name.to_sym
156
+ facts[name] = Absent unless facts.key?(name)
157
+ end
158
+ facts
159
+ end
160
+
161
+ private
162
+
163
+ # **The port operation shape** — formerly `Dispatcher#port_invocation`.
164
+ #
165
+ # A Reference-typed attribute naming the owning aggregate is routing,
166
+ # not a fact: lifted out of the loose kwargs into `to:` when no `to:`
167
+ # was given. A `to:`-declared operation carries no Reference
168
+ # attribute at all (PortOperationBuilder#initialize's own comment),
169
+ # so its receiver is read — not removed — from the plain attribute
170
+ # named for the owner's first `identified_by` field: that attribute
171
+ # is still a declared external fact `refuse_absent_arguments`
172
+ # expects to find (Rust's own comment: "declare only external facts
173
+ # with attribute"; a real AbsentArgument confirmed this before `[]`
174
+ # replaced `delete`). Composite identity is not attempted — `.first`
175
+ # only, no domain in the corpus needs more for a port operation.
176
+ def port_call(verb, aggregate, operation, to:, with:, legacy:)
177
+ legacy = legacy.dup
178
+ identity = operation.identity_attribute(aggregate.hecks_name)
179
+ if to.nil? && identity && legacy.key?(identity.name)
180
+ to = legacy.delete(identity.name)
181
+ elsif to.nil? && operation.to == aggregate.hecks_name
182
+ identity_name = Array(aggregate.identified_by).first
183
+ to = legacy[identity_name] if identity_name && legacy.key?(identity_name)
184
+ end
185
+
186
+ target = route(to)
187
+ raise TypeMismatch, "#{operation.hecks_name} requires its receiving aggregate in to:" unless target
188
+
189
+ new(verb: verb, target: target, facts: facts_for(operation, with: with, legacy: legacy))
190
+ end
191
+
192
+ # BUG#7 — a non-Hash `to:` must be a String, matching Rust's
193
+ # `RoutingEnvelope::from_json` (kernel/routing.rs), which refuses
194
+ # anything neither a JSON string nor object before the domain payload
195
+ # is examined. Surfaced on `examples/roster`'s `Mark`, whose own
196
+ # attribute is literally named `to`: a flat-kwargs dispatch steals
197
+ # that key into this parameter, and an out-of-range Integer used to be
198
+ # accepted as the aggregate identity, leaving Mark's `to` fact absent
199
+ # (AbsentArgument in Ruby, TypeMismatch in Rust). See
200
+ # spec/runtime/routing_envelope_shape_spec.rb.
201
+ def scalar_envelope(to)
202
+ return [to, []] if to.is_a?(String)
203
+
204
+ raise TypeMismatch, "to: must be a string aggregate identity or an entity route, got #{Rendering.describe(to)}"
205
+ end
206
+
207
+ def envelope_hash(to)
208
+ hash = to.transform_keys(&:to_sym)
209
+ unknown = hash.keys - %i[aggregate entity entities]
210
+ raise TypeMismatch, "to: does not recognize #{unknown.sort.join(', ')}" unless unknown.empty?
211
+
212
+ [hash[:aggregate], entity_identities(hash)]
213
+ end
214
+
215
+ # BUG#18 — an entity route naming no entity (`entities: []`, or
216
+ # neither key) refuses here, unconditionally, before entity_depth is
217
+ # consulted: for an aggregate-level command (depth 0) `[].size == 0`
218
+ # used to satisfy the depth check and let the degenerate Hash reach
219
+ # the command's own validation. Rust's `RoutingEnvelope::from_json`
220
+ # always refused it at this point. A bare aggregate identity String
221
+ # remains the ordinary aggregate-command shape.
222
+ def entity_identities(hash)
223
+ raise TypeMismatch, "to: takes entity: or entities:, not both" if hash.key?(:entities) && hash.key?(:entity)
224
+
225
+ identities = hash.key?(:entities) ? Array(hash[:entities]) : Array(hash[:entity])
226
+ raise TypeMismatch, "to: entity route requires at least one entity identity" if identities.empty?
227
+
228
+ identities
229
+ end
230
+
231
+ # `with:` is deliberately strict: a caller choosing the explicit
232
+ # envelope cannot smuggle receiver identity back into the payload,
233
+ # and may not mix it with loose keyword arguments. Without `with:`
234
+ # (nil or false) the loose keyword arguments are the facts, unread —
235
+ # whether `to:` was given never enters this decision (BUG#17).
236
+ def offered_facts(declaring, with:, legacy:)
237
+ if with && !legacy.empty?
238
+ raise TypeMismatch,
239
+ "dispatch takes command facts in with:, not both with: and loose keyword arguments"
240
+ end
241
+
242
+ return legacy unless with
243
+ raise TypeMismatch, "with: must be a hash of command facts" unless with.is_a?(Hash)
244
+
245
+ offered = with.transform_keys(&:to_sym)
246
+ declared = declaring.attributes.map { |attribute| attribute.name.to_sym }
247
+ refuse_unknown_facts!(declaring, offered, declared)
248
+ refuse_absent_facts!(declaring, offered, declared)
249
+ offered
250
+ end
251
+
252
+ # Unknown before absent — a `with:` that is both still refuses
253
+ # UnknownArgument first.
254
+ def refuse_unknown_facts!(declaring, offered, declared)
255
+ unknown = (offered.keys - declared).sort
256
+ return if unknown.empty?
257
+
258
+ raise UnknownArgument,
259
+ RefusalWording.render_site("UnknownArgument", "unknown_args",
260
+ command: declaring.hecks_name, unknown: unknown,
261
+ declared: declared)
262
+ end
263
+
264
+ def refuse_absent_facts!(declaring, offered, declared)
265
+ absent = declaring.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } - offered.keys
266
+ return if absent.empty?
267
+
268
+ raise AbsentArgument,
269
+ RefusalWording.render_site("AbsentArgument", "absent_args",
270
+ command: declaring.hecks_name, absent: absent,
271
+ declared: declared)
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end