hecks 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +7 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +10 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +8 -8
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +31 -13
  7. data/lib/hecks/adapters/driven/heki/journal.rb +60 -2
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +5 -5
  9. data/lib/hecks/adapters/driven/heki.rb +13 -7
  10. data/lib/hecks/adapters/driven/identity_registry.rb +2 -2
  11. data/lib/hecks/adapters/driven/in_memory_ordering.rb +3 -3
  12. data/lib/hecks/adapters/driven/lambda/client.rb +34 -9
  13. data/lib/hecks/adapters/driven/lambda.rb +39 -33
  14. data/lib/hecks/adapters/driven/local_storage.rb +17 -10
  15. data/lib/hecks/adapters/driven/memory.rb +205 -9
  16. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +1 -1
  17. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  18. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  19. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  20. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  21. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  22. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  23. data/lib/hecks/adapters/driven/prism.rb +4 -4
  24. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  25. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  26. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  27. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  28. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  29. data/lib/hecks/adapters/driven.rb +4 -4
  30. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  31. data/lib/hecks/behaviors/dsl.rb +2 -2
  32. data/lib/hecks/behaviors/expectations.rb +51 -23
  33. data/lib/hecks/behaviors/ir.rb +1 -1
  34. data/lib/hecks/behaviors/rspec.rb +1 -1
  35. data/lib/hecks/behaviors/runner.rb +2 -2
  36. data/lib/hecks/behaviors.rb +1 -1
  37. data/lib/hecks/bluebook/aggregate.rb +13 -13
  38. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
  39. data/lib/hecks/bluebook/assembly/build.rb +1 -1
  40. data/lib/hecks/bluebook/assembly/contract.rb +39 -16
  41. data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
  42. data/lib/hecks/bluebook/assembly/marks.rb +18 -18
  43. data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
  44. data/lib/hecks/bluebook/assembly.rb +9 -9
  45. data/lib/hecks/bluebook/attribute.rb +9 -9
  46. data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
  47. data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
  48. data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
  49. data/lib/hecks/bluebook/behaviour/command.rb +23 -23
  50. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -3
  51. data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
  52. data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
  53. data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
  54. data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
  55. data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
  56. data/lib/hecks/bluebook/behaviour/query.rb +1 -1
  57. data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
  58. data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
  59. data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
  60. data/lib/hecks/bluebook/capabilities.rb +27 -0
  61. data/lib/hecks/bluebook/chapter.rb +28 -9
  62. data/lib/hecks/bluebook/command.rb +12 -12
  63. data/lib/hecks/bluebook/domain_port.rb +9 -9
  64. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  65. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  66. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
  67. data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
  68. data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
  69. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
  70. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
  71. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  72. data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
  73. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  74. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  75. data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
  76. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  77. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
  78. data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
  79. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  80. data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
  81. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  82. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  83. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
  84. data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
  85. data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
  86. data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
  87. data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
  88. data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
  89. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  90. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  91. data/lib/hecks/bluebook/entity.rb +11 -11
  92. data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
  93. data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
  94. data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
  95. data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
  96. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
  97. data/lib/hecks/bluebook/expression/resolver.rb +60 -62
  98. data/lib/hecks/bluebook/hexagon.rb +1 -1
  99. data/lib/hecks/bluebook/lifecycle.rb +1 -1
  100. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
  101. data/lib/hecks/bluebook/meta_validator/judge.rb +122 -86
  102. data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
  103. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
  104. data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
  105. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
  106. data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
  107. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
  108. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
  109. data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
  110. data/lib/hecks/bluebook/meta_validator.rb +70 -70
  111. data/lib/hecks/bluebook/model_check.rb +301 -84
  112. data/lib/hecks/bluebook/pattern_subset.rb +9 -9
  113. data/lib/hecks/bluebook/policy.rb +15 -13
  114. data/lib/hecks/bluebook/process_manager.rb +14 -14
  115. data/lib/hecks/bluebook/project_register.rb +6 -6
  116. data/lib/hecks/bluebook/query.rb +4 -4
  117. data/lib/hecks/bluebook/read_model.rb +14 -14
  118. data/lib/hecks/bluebook/reference.rb +8 -8
  119. data/lib/hecks/bluebook/smoke_test.rb +19 -19
  120. data/lib/hecks/bluebook/synthesizer.rb +12 -12
  121. data/lib/hecks/bluebook/translation.rb +4 -4
  122. data/lib/hecks/bluebook/value_object.rb +6 -6
  123. data/lib/hecks/bluebook.rb +2 -2
  124. data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
  125. data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
  126. data/lib/hecks/codemod.rb +36 -35
  127. data/lib/hecks/construct.rb +6 -6
  128. data/lib/hecks/corpus.rb +317 -0
  129. data/lib/hecks/deprecation.rb +95 -0
  130. data/lib/hecks/doc/reference.rb +19 -19
  131. data/lib/hecks/embryonaut_bluebook.rb +11 -11
  132. data/lib/hecks/facade/cli_door.rb +69 -10
  133. data/lib/hecks/facade/cli_runner.rb +105 -24
  134. data/lib/hecks/facade/command_request.rb +23 -0
  135. data/lib/hecks/facade/handle.rb +79 -32
  136. data/lib/hecks/facade/json_door.rb +106 -25
  137. data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
  138. data/lib/hecks/facade/surface/chapter.rb +26 -17
  139. data/lib/hecks/facade/surface.rb +16 -3
  140. data/lib/hecks/facade.rb +15 -4
  141. data/lib/hecks/forms/app.rb +46 -30
  142. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  143. data/lib/hecks/forms/field_renderer.rb +142 -6
  144. data/lib/hecks/forms/field_shape.rb +183 -20
  145. data/lib/hecks/forms/html.rb +51 -7
  146. data/lib/hecks/forms/index_renderer.rb +14 -2
  147. data/lib/hecks/forms/params.rb +120 -23
  148. data/lib/hecks/forms/port_argument.rb +2 -2
  149. data/lib/hecks/forms/query_form_renderer.rb +2 -2
  150. data/lib/hecks/forms/record_renderer.rb +2 -2
  151. data/lib/hecks/forms/record_table.rb +1 -1
  152. data/lib/hecks/forms/value_object_shape.rb +3 -3
  153. data/lib/hecks/forms.rb +24 -4
  154. data/lib/hecks/fqn.rb +1 -1
  155. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  156. data/lib/hecks/framework.rb +48 -17
  157. data/lib/hecks/freezer.rb +11 -11
  158. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
  159. data/lib/hecks/fuzzing/combination_miner.rb +119 -0
  160. data/lib/hecks/fuzzing/concurrent_dispatch.rb +361 -0
  161. data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
  162. data/lib/hecks/fuzzing/differential.rb +158 -0
  163. data/lib/hecks/fuzzing/domain_generator.rb +694 -0
  164. data/lib/hecks/fuzzing/era_boundary.rb +124 -0
  165. data/lib/hecks/fuzzing/form_census.rb +199 -0
  166. data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
  167. data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
  168. data/lib/hecks/fuzzing/isolated_boot.rb +226 -38
  169. data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
  170. data/lib/hecks/fuzzing/persistence_parity.rb +161 -0
  171. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  172. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +321 -41
  173. data/lib/hecks/fuzzing/properties/guards.rb +129 -26
  174. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
  175. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +31 -11
  176. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  177. data/lib/hecks/fuzzing/properties/querying.rb +17 -17
  178. data/lib/hecks/fuzzing/properties.rb +84 -39
  179. data/lib/hecks/fuzzing/qa_settings.rb +152 -0
  180. data/lib/hecks/fuzzing/replay.rb +224 -114
  181. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  182. data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
  183. data/lib/hecks/fuzzing/self_consistency.rb +676 -0
  184. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  185. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +101 -30
  186. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +55 -8
  187. data/lib/hecks/fuzzing/sequence_generator/picker.rb +31 -11
  188. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +123 -32
  189. data/lib/hecks/fuzzing/sequence_generator.rb +152 -31
  190. data/lib/hecks/fuzzing/shrinker.rb +197 -0
  191. data/lib/hecks/fuzzing/structural_skips.rb +39 -0
  192. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  193. data/lib/hecks/fuzzing/target_capabilities.rb +180 -0
  194. data/lib/hecks/fuzzing/value_generator.rb +66 -14
  195. data/lib/hecks/fuzzing.rb +11 -0
  196. data/lib/hecks/grammar/evolve.rb +10 -10
  197. data/lib/hecks/grammar.rb +7 -7
  198. data/lib/hecks/ir.rb +13 -13
  199. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  200. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  201. data/lib/hecks/language/bluebook/vocabulary.bluebook +365 -15
  202. data/lib/hecks/language/oidc.json +5 -0
  203. data/lib/hecks/literal.rb +9 -9
  204. data/lib/hecks/naming.rb +89 -21
  205. data/lib/hecks/ports/access_control.rb +58 -2
  206. data/lib/hecks/ports/agent/answers.rb +83 -6
  207. data/lib/hecks/ports/agent.rb +119 -35
  208. data/lib/hecks/ports/authentication.rb +44 -4
  209. data/lib/hecks/ports/authorization.rb +53 -11
  210. data/lib/hecks/ports/clock.rb +42 -23
  211. data/lib/hecks/ports/extraction.rb +16 -0
  212. data/lib/hecks/ports/identity_assignment.rb +24 -2
  213. data/lib/hecks/ports/identity_generation.rb +17 -3
  214. data/lib/hecks/ports/identity_resolution.rb +18 -1
  215. data/lib/hecks/ports/loading.rb +4 -0
  216. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  217. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  218. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  219. data/lib/hecks/ports/persistence/execution.rb +4 -0
  220. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  221. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  222. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  223. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  224. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  225. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  226. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  227. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  228. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  229. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  230. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  231. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +160 -58
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +44 -6
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +338 -83
  242. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  243. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  244. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  245. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
  246. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  247. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  248. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  249. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
  250. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
  251. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
  252. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  253. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  254. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  255. data/lib/hecks/ports/persistence/repository_factory.rb +29 -6
  256. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  257. data/lib/hecks/ports/persistence.rb +36 -1
  258. data/lib/hecks/ports/projection.rb +61 -7
  259. data/lib/hecks/ports/query/in_memory.rb +3 -3
  260. data/lib/hecks/ports/query/ordering.rb +6 -6
  261. data/lib/hecks/ports/query.rb +35 -0
  262. data/lib/hecks/projections/bootstrap_table.rb +112 -0
  263. data/lib/hecks/projections/diagrams.rb +75 -75
  264. data/lib/hecks/projections/glossary/html.rb +250 -0
  265. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  266. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  267. data/lib/hecks/projections/glossary/page.css +271 -0
  268. data/lib/hecks/projections/glossary/page.js +72 -0
  269. data/lib/hecks/projections/glossary/sections.rb +17 -0
  270. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  271. data/lib/hecks/projections/glossary.rb +214 -286
  272. data/lib/hecks/projections/ir.rb +1 -1
  273. data/lib/hecks/projections/model/deviations.rb +18 -17
  274. data/lib/hecks/projections/model.rb +25 -21
  275. data/lib/hecks/projections/oidc.rb +7 -7
  276. data/lib/hecks/projections/parser_table.rb +5 -5
  277. data/lib/hecks/projections/reference.rb +3 -3
  278. data/lib/hecks/projections/rust_vocabulary.rb +443 -0
  279. data/lib/hecks/projections/shape.rb +2 -2
  280. data/lib/hecks/projections/statements.rb +11 -11
  281. data/lib/hecks/projections/vocabulary.rb +9 -9
  282. data/lib/hecks/projections.rb +5 -3
  283. data/lib/hecks/projector/cli_projector.rb +29 -29
  284. data/lib/hecks/projector/docs_projector.rb +13 -13
  285. data/lib/hecks/projector/exporter.rb +42 -21
  286. data/lib/hecks/projector/ir_projector.rb +1 -1
  287. data/lib/hecks/projector/narrate_projector.rb +15 -22
  288. data/lib/hecks/projector/target.rb +13 -13
  289. data/lib/hecks/projector.rb +15 -15
  290. data/lib/hecks/query_ir.rb +47 -47
  291. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  292. data/lib/hecks/query_specification/common/comparison.rb +132 -24
  293. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  294. data/lib/hecks/query_specification/common/null_policy.rb +57 -13
  295. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  296. data/lib/hecks/query_specification/common/options.rb +25 -0
  297. data/lib/hecks/query_specification/field_path.rb +69 -15
  298. data/lib/hecks/query_specification/hop_path.rb +57 -20
  299. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  300. data/lib/hecks/rendering.rb +3 -3
  301. data/lib/hecks/router/namespace_installer.rb +3 -3
  302. data/lib/hecks/router.rb +1 -1
  303. data/lib/hecks/runtime/aggregate_lock.rb +11 -11
  304. data/lib/hecks/runtime/boot_gates.rb +3 -3
  305. data/lib/hecks/runtime/caller.rb +8 -8
  306. data/lib/hecks/runtime/capability_graph.rb +2 -2
  307. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
  308. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +64 -67
  309. data/lib/hecks/runtime/command_interpreter.rb +159 -102
  310. data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
  311. data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
  312. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  313. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  314. data/lib/hecks/runtime/command_rules/references.rb +94 -19
  315. data/lib/hecks/runtime/command_rules.rb +3 -0
  316. data/lib/hecks/runtime/dependency_planning.rb +11 -11
  317. data/lib/hecks/runtime/dispatcher.rb +283 -90
  318. data/lib/hecks/runtime/entity_element.rb +282 -46
  319. data/lib/hecks/runtime/entity_interpreter.rb +160 -69
  320. data/lib/hecks/runtime/errors.rb +19 -19
  321. data/lib/hecks/runtime/event.rb +6 -6
  322. data/lib/hecks/runtime/identity.rb +22 -22
  323. data/lib/hecks/runtime/instance.rb +39 -14
  324. data/lib/hecks/runtime/interpreting.rb +12 -12
  325. data/lib/hecks/runtime/invocation.rb +276 -0
  326. data/lib/hecks/runtime/loader.rb +14 -14
  327. data/lib/hecks/runtime/outbox.rb +23 -23
  328. data/lib/hecks/runtime/policy_interpreter.rb +54 -54
  329. data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
  330. data/lib/hecks/runtime/query_interpreter.rb +111 -56
  331. data/lib/hecks/runtime/reaction_invocation.rb +76 -9
  332. data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
  333. data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
  334. data/lib/hecks/runtime/reference_hop.rb +6 -6
  335. data/lib/hecks/runtime/refusal_wording.rb +92 -112
  336. data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
  337. data/lib/hecks/runtime/registry/verification.rb +36 -26
  338. data/lib/hecks/runtime/registry.rb +56 -27
  339. data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
  340. data/lib/hecks/runtime/routing.rb +10 -88
  341. data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
  342. data/lib/hecks/runtime/saga_interpreter.rb +93 -56
  343. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  344. data/lib/hecks/runtime/tenant_check.rb +9 -9
  345. data/lib/hecks/runtime/tenant_scope.rb +5 -5
  346. data/lib/hecks/runtime/value/admission.rb +75 -30
  347. data/lib/hecks/runtime/value/coercion.rb +379 -226
  348. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  349. data/lib/hecks/runtime/value.rb +28 -23
  350. data/lib/hecks/runtime.rb +7 -7
  351. data/lib/hecks/storehouse.rb +64 -64
  352. data/lib/hecks/version.rb +3 -3
  353. data/lib/hecks/vocabulary.rb +207 -5
  354. data/lib/hecks.rb +13 -11
  355. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
  356. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
  357. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
  358. metadata +43 -2
@@ -19,7 +19,7 @@ module Hecks
19
19
  include Interpreting
20
20
  include CommandInterpreter::ArgumentGate
21
21
 
22
- Context = Struct.new(:domain, :aggregate, :operation, :args, :route, :instance, :result)
22
+ Context = Struct.new(:domain, :aggregate, :operation, :args, :route, :instance, :result, :invocation)
23
23
 
24
24
  DISPATCH_ORDER = %i[
25
25
  refuse_unknown_arguments refuse_absent_arguments normalize_args resolve_references resolve_route emit
@@ -30,9 +30,12 @@ module Hecks
30
30
  @rules = rules
31
31
  end
32
32
 
33
- def call(domain, aggregate, operation, args, route:)
34
- ctx = Context.new(domain, aggregate, operation, args)
35
- ctx.route = route
33
+ # `invocation` the `Runtime::Invocation` `Dispatcher` built;
34
+ # `ctx.args` is its `to_args`, `ctx.route` its `target`.
35
+ def call(domain, aggregate, operation, invocation)
36
+ ctx = Context.new(domain, aggregate, operation, invocation.to_args)
37
+ ctx.invocation = invocation
38
+ ctx.route = invocation.target
36
39
  run_dispatch_order(DISPATCH_ORDER, ctx)
37
40
  ctx.result
38
41
  end
@@ -66,13 +69,13 @@ module Hecks
66
69
  ctx.result = step(:emit) { ctx.operation.outbound? ? ask(ctx) : emit(ctx) }
67
70
  end
68
71
 
69
- # THE DOMAIN CALLING OUT, AND BOTH ENDINGS RECORDED.
72
+ # The domain calling out, and both endings recorded.
70
73
  #
71
74
  # The adapter is found the same way every other port's is — by name,
72
75
  # across whatever adapters this boot loaded — so an `asks` is bound by
73
76
  # an adapter declaring `port "IssueTracker"` and nothing new to learn.
74
77
  #
75
- # EVERY FAILURE IS AN ANSWER. A raise from the far side of a boundary is
78
+ # Every failure is an answer. A raise from the far side of a boundary is
76
79
  # not an exception in this domain's terms, it is the outside saying no,
77
80
  # and the chapter already named the word for that. So the rescue is
78
81
  # deliberately wide: a timeout, a bad credential, an adapter that does
@@ -80,22 +83,22 @@ module Hecks
80
83
  # `refuses` event, carrying what was said. A policy reacts to it, a
81
84
  # retry counter reads it, and nothing has to catch anything.
82
85
  #
83
- # AN ASK IS HANDED THE RECORD IT IS ABOUT.
86
+ # An ask is handed the record it is about.
84
87
  #
85
- # An INBOUND operation deliberately cannot read state — it is the
88
+ # An inbound operation deliberately cannot read state — it is the
86
89
  # anti-corruption boundary, translating a fact from outside, and letting
87
90
  # it read the aggregate would make it a second place rules live. That
88
91
  # rule was written for that direction and does not survive the crossing.
89
92
  #
90
93
  # An outbound one almost always needs the record. `asks "File"` names
91
94
  # `reference_to Ticket` and the adapter needs the ticket's repository,
92
- # title and body — which are ON the ticket, and which the policy that
95
+ # title and body — which are on the ticket, and which the policy that
93
96
  # triggered this cannot supply because a command's event payload is its
94
- # ARGUMENTS, not its state. Without this, every ask would have to have
97
+ # arguments, not its state. Without this, every ask would have to have
95
98
  # its data re-passed through the command that fired it, so the same text
96
99
  # would live in two places and could differ.
97
100
  #
98
- # ARGUMENTS WIN over state, because an argument is what THIS call said
101
+ # Arguments win over state, because an argument is what this call said
99
102
  # and state is what the record happens to hold.
100
103
  def ask(ctx)
101
104
  payload = held_state(ctx).merge(materialise(ctx.args))
@@ -105,13 +108,13 @@ module Hecks
105
108
  announce(ctx, ctx.operation.refuses, ctx.args.merge(refusal: { value: "#{e.class}: #{e.message}" }))
106
109
  end
107
110
 
108
- # THE ANSWER IS SPREAD, NOT NESTED — and that is what makes the loop
109
- # close. A policy re-enters its target with the event payload VERBATIM;
111
+ # The answer is spread, not nested — and that is what makes the loop
112
+ # close. A policy re-enters its target with the event payload verbatim;
110
113
  # it cannot reach inside a key. So an answer tucked under `answered:`
111
114
  # can be read by a human and by nothing else, and the command that
112
115
  # should record the issue number never gets one.
113
116
  #
114
- # Spread, the adapter's own keys ARE the arguments of whatever command
117
+ # Spread, the adapter's own keys are the arguments of whatever command
115
118
  # reacts to the answering event. Which is a real contract on the adapter
116
119
  # — it must return what that command takes, in the shape the runtime
117
120
  # coerces (`{ number: { value: 43 } }`, not `43`) — and naming it here
@@ -134,7 +137,7 @@ module Hecks
134
137
  end
135
138
  end
136
139
 
137
- # THE RECORD, IF THERE IS ONE. A record that does not exist yet is not
140
+ # The record, if there is one. A record that does not exist yet is not
138
141
  # an error here — the ask still goes, carrying only its arguments, and
139
142
  # whatever the adapter makes of that is its own business. Refusing
140
143
  # would put a second existence check behind the one `resolve_references`
@@ -145,10 +148,10 @@ module Hecks
145
148
  {}
146
149
  end
147
150
 
148
- # THE PORT THIS OPERATION BELONGS TO, found by asking the aggregate
151
+ # The port this operation belongs to, found by asking the aggregate
149
152
  # rather than threading it through the call — the dispatcher already
150
153
  # resolved it once to get here, and a second parameter carried purely so
151
- # this method can read it would be a parameter every OTHER step ignores.
154
+ # this method can read it would be a parameter every other step ignores.
152
155
  def port_name_for(ctx)
153
156
  owning = ctx.aggregate.ports.find { |port| port.operations.any? { |op| op.equal?(ctx.operation) } }
154
157
  owning&.name or raise WiringError,
@@ -185,9 +188,9 @@ module Hecks
185
188
  [event]
186
189
  end
187
190
 
188
- # THE ONE PLACE THIS DIFFERS FROM CommandRules::Emission — there is no
191
+ # The one place this differs from CommandRules::Emission — there is no
189
192
  # mutated instance to read an id off, because nothing was hydrated or
190
- # saved. The record this event is ABOUT is named by whichever attribute
193
+ # saved. The record this event is about is named by whichever attribute
191
194
  # is a reference to the owning aggregate (PortOperationBuilder#build
192
195
  # already refused to build an operation with none), so its coerced
193
196
  # value — already a plain id, never an object, per
@@ -15,7 +15,7 @@ module Hecks
15
15
  # repository: prefers a native adapter hook (Ports::Query.execute)
16
16
  # when the store can answer directly, falling back to interpreting
17
17
  # wheres/order_by/limit/offset over every loaded record itself.
18
- # #reference_call/#reference_interpret are a SEPARATE, deliberately
18
+ # #reference_call/#reference_interpret are a separate, deliberately
19
19
  # naive re-implementation of the same evaluation, used only as the
20
20
  # fuzzer's oracle to catch divergence between adapters and this
21
21
  # interpreter's own native path.
@@ -32,10 +32,10 @@ module Hecks
32
32
  declared = declared_query(aggregate, query_name)
33
33
  args = normalize_args(aggregate, declared, args)
34
34
  declared = TenantScope.apply(declared, args)
35
- # AFTER TenantScope, so its synthetic clause is already present
36
- # in `.wheres` and rides through as an ordinary LOCAL clause on
37
- # the OUTER query. It does not reach the hop's own inner
38
- # sub-query against the TARGET aggregate — a hop's target may
35
+ # After TenantScope, so its synthetic clause is already present
36
+ # in `.wheres` and rides through as an ordinary local clause on
37
+ # the outer query. It does not reach the hop's own inner
38
+ # sub-query against the target aggregate — a hop's target may
39
39
  # not even declare the same tenant boundary, and propagating one
40
40
  # aggregate's tenant scope onto an unrelated aggregate's own
41
41
  # query is a real design question of its own, not answered here.
@@ -51,14 +51,14 @@ module Hecks
51
51
  if (native = Ports::Query.execute(repository, declared, args,
52
52
  context: { domain: domain, aggregate: aggregate, registry: @registry }))
53
53
  records = native
54
- # `record.state.merge(id: record.id)` — id LAST, not first. See
54
+ # `record.state.merge(id: record.id)` — id last, not first. See
55
55
  # Instance#to_h's own comment: an aggregate free to declare its
56
56
  # own attribute literally named `id` has that attribute's own
57
57
  # wrapped value sitting in `record.state[:id]` already; merging
58
- # it OVER a `{id:}.merge(state)` used to let it silently
58
+ # it over a `{id:}.merge(state)` used to let it silently
59
59
  # clobber the correct bare identity this row is supposed to
60
60
  # carry.
61
- # A QUERY ROW IS AN ANSWER, NOT A HANDLE. Mutating one edits
61
+ # A query row is an answer, not a handle. Mutating one edits
62
62
  # nobody's state and silently disagrees with the store.
63
63
  return Freezer.deep(records.map { |record| record.state.merge(id: record.id) })
64
64
  end
@@ -66,18 +66,18 @@ module Hecks
66
66
  Freezer.deep(interpret(repository.all, declared, args, domain: domain))
67
67
  end
68
68
 
69
- # The REFERENCE answer — this interpreter's own evaluation, never an
69
+ # The reference answer — this interpreter's own evaluation, never an
70
70
  # adapter's native hook. The fuzzer's query oracle replays every
71
71
  # generated ask through both paths and treats a difference as a
72
72
  # finding: the differential gate the retired cross-runtime harness
73
73
  # should always have been, aimed where the divergence actually
74
74
  # lives — between the engines inside this one runtime.
75
75
  #
76
- # A hop clause is answered here by its OWN, deliberately naive
76
+ # A hop clause is answered here by its own, deliberately naive
77
77
  # walk (reference_where_holds?) — never Runtime::ReferenceHop's
78
- # partition/fold/IN-clause. Sharing that algorithm would have made
78
+ # partition/fold/in-clause. Sharing that algorithm would have made
79
79
  # this oracle blind to exactly the code the hop feature adds: every
80
- # PHASE of a shared fold would still get diffed against the native
80
+ # phase of a shared fold would still get diffed against the native
81
81
  # adapters, but the fold itself — the empty candidate set, a
82
82
  # duplicate id, a dangling reference, a chain's inside-out
83
83
  # resolution order — would only ever be compared against itself.
@@ -95,14 +95,14 @@ module Hecks
95
95
 
96
96
  def declared_query(aggregate, query_name)
97
97
  aggregate.query(query_name) ||
98
- raise(UnknownVerb, RefusalWording.render("UnknownVerb", "no_query",
99
- aggregate: aggregate.hecks_name, query: query_name.inspect))
98
+ raise(UnknownVerb, RefusalWording.render_site("UnknownVerb", "no_query",
99
+ aggregate: aggregate.hecks_name, query: query_name))
100
100
  end
101
101
 
102
102
  def interpret(records, declared, args, domain: nil)
103
103
  matched = records.select { |r| declared.wheres.all? { |w| where_holds?(w, r, args, domain: domain) } }
104
104
  ordered = ordered(matched, declared.order_by, declared.null_semantics)
105
- # OFFSET FIRST, THEN LIMIT — the order SQL means by `LIMIT n
105
+ # **Offset first, then limit** — the order SQL means by `LIMIT n
106
106
  # OFFSET m`, and the order Ports::Query::InMemory#execute already
107
107
  # applies (see that file's own comment). This interpreter used to
108
108
  # never read declared.offset at all — offset silently vanished for
@@ -110,7 +110,7 @@ module Hecks
110
110
  skipped = declared.offset ? ordered.drop(resolve_query_value(declared.offset.value, args).to_i) : ordered
111
111
  capped = declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
112
112
 
113
- # id LAST — see the native-path comment above; same clobbering
113
+ # id last — see the native-path comment above; same clobbering
114
114
  # risk for the in-memory reference interpreter's own rows.
115
115
  capped.map { |r| r.state.merge(id: r.id) }
116
116
  end
@@ -120,7 +120,7 @@ module Hecks
120
120
  # reference is answered by reference_where_holds? instead of the
121
121
  # plain FieldPath.dig(record, field) `where_holds?` uses (which
122
122
  # has no concept of a reference at all — it would just read the
123
- # raw id straight off the record and compare THAT).
123
+ # raw id straight off the record and compare that).
124
124
  def reference_interpret(records, declared, args, domain:, shape:)
125
125
  matched = records.select do |r|
126
126
  declared.wheres.all? do |w|
@@ -128,20 +128,20 @@ module Hecks
128
128
  end
129
129
  end
130
130
  ordered = ordered(matched, declared.order_by, declared.null_semantics)
131
- # OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
131
+ # **Offset first, then limit** — same fix, same reasoning, as
132
132
  # #interpret's own rows above.
133
133
  skipped = declared.offset ? ordered.drop(resolve_query_value(declared.offset.value, args).to_i) : ordered
134
134
  capped = declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
135
135
 
136
- # id LAST — same reasoning, same fix, as interpret's own rows.
136
+ # id last — same reasoning, same fix, as interpret's own rows.
137
137
  capped.map { |r| r.state.merge(id: r.id) }
138
138
  end
139
139
 
140
- # THE NAIVE READING OF A HOP: not a fold, not an id set — for
140
+ # **The naive reading of a hop**: not a fold, not an id set — for
141
141
  # each candidate row, walk the reference by hand and dig the
142
142
  # field out of whatever it actually points at. A nil reference,
143
143
  # or one that resolves to nothing (a dangling id), makes the
144
- # WHOLE clause false outright, whatever the comparator — "points
144
+ # whole clause false outright, whatever the comparator — "points
145
145
  # at a client that is not active" is false for a proposal with no
146
146
  # client at all, the same way it is false for one whose client
147
147
  # really is active; falling through to holds?(clause, nil, args)
@@ -177,8 +177,8 @@ module Hecks
177
177
 
178
178
  ordered = ordered_elements(rows, declared.order_by, declared.null_semantics,
179
179
  parent_key, entity.identity_heads)
180
- # OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
181
- # #interpret's own rows above. `entity_rows` is the ONLY engine
180
+ # **Offset first, then limit** — same fix, same reasoning, as
181
+ # #interpret's own rows above. `entity_rows` is the only engine
182
182
  # for entity/sub-list queries, so a declared offset here silently
183
183
  # vanished for every entity query, not merely one path among
184
184
  # several.
@@ -186,31 +186,28 @@ module Hecks
186
186
  declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
187
187
  end
188
188
 
189
- # THE THREE DECLARATIONS `entity_rows` NEEDS BEFORE IT CAN READ A
190
- # SINGLE RECORD — the entity itself, its declared query, and the
189
+ # The three declarations `entity_rows` needs before it can read a
190
+ # single record — the entity itself, its declared query, and the
191
191
  # list attribute that holds it on the aggregate. Extracted from
192
192
  # `entity_rows` (pure extraction, same lookups, same order, same
193
193
  # UnknownVerb refusals) purely to separate "which declarations does
194
194
  # this dotted name resolve to" from the row-computation that follows.
195
195
  def resolve_entity_query(aggregate, entity_name, query_name)
196
196
  entity = aggregate.entities.find { |piece| piece.hecks_name == entity_name } ||
197
- raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_unknown",
198
- aggregate: aggregate.hecks_name, entity: entity_name.inspect))
197
+ raise(UnknownVerb, RefusalWording.render_site("UnknownVerb", "entity_unknown",
198
+ aggregate: aggregate.hecks_name, entity: entity_name))
199
199
  declared = entity.query(query_name) ||
200
- raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_query_missing",
201
- entity: entity_name, query: query_name.inspect))
200
+ raise(UnknownVerb, RefusalWording.render_site("UnknownVerb", "entity_query_missing",
201
+ entity: entity_name, query: query_name))
202
202
  list_attr = aggregate.attributes.find { |a| a.list? && a.type.to_s == entity_name } ||
203
- raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_holds_no_list",
204
- aggregate: aggregate.hecks_name, entity: entity_name))
203
+ raise(UnknownVerb, RefusalWording.render_site("UnknownVerb", "entity_holds_no_list",
204
+ aggregate: aggregate.hecks_name, entity: entity_name))
205
205
  [entity, declared, list_attr]
206
206
  end
207
207
 
208
- # FieldPath.dig, not a raw `element[clause.field.to_sym]` — an
209
- # entity sub-list row is a plain hash merged from stored state
210
- # (mixed string/symbol keys depending on adapter, per `#cell`'s own
211
- # comment below), and a dotted `where` (`where "price.cents" < 100`)
212
- # needs the same segment-by-segment walk every other query path
213
- # already gets. Reading only the symbol spelling of the WHOLE
208
+ # FieldPath.dig, not a raw `element[clause.field.to_sym]` — a dotted
209
+ # `where` (`where "price.cents" < 100`) needs the same segment-by-
210
+ # segment walk every other query path already gets. Reading the whole
214
211
  # dotted string as one key always missed — `element[:"price.cents"]`
215
212
  # is never a real key — so a dotted where on an entity query
216
213
  # silently matched nothing, on the only engine entity queries have.
@@ -218,28 +215,22 @@ module Hecks
218
215
  holds?(clause, QuerySpecification::FieldPath.dig(element, clause.field), args)
219
216
  end
220
217
 
221
- # A row's own key, however the store spells it. A sub-list row is a plain hash
222
- # merged from stored state, so its keys arrive as strings from one adapter and
223
- # symbols from anotherand reading only one spelling gave every row the SAME
224
- # identity, which is a tie, which is the exact nondeterminism this tier exists
225
- # to remove. It rides `comparable` for the same reason a where-clause does : an
226
- # identity is a value object, and `to_s` on one is an OBJECT ADDRESS — a sort key
218
+ # A row's own key. A sub-list row is a hydrated entity element (symbol-keyed
219
+ # since every adapter decodes through `Ports::Persistence::StateCodec` and
220
+ # `EntityListCoercion#hydrate_entity_list` symbolizes each element PR A4
221
+ # removed the string-spelling fallback that coped with the old per-adapter
222
+ # shapes). It rides `comparable` for the same reason a where-clause does : an
223
+ # identity is a value object, and `to_s` on one is an object address — a sort key
227
224
  # that differs run to run, which is worse than the store order it replaced.
228
- # `key?` decides which spelling answers, never `||` — a row whose
229
- # value is a genuinely-held `false` must not fall through to the
230
- # other spelling (usually absent) and land on `nil`.
231
- def cell(row, key)
232
- sym = key.to_sym
233
- row.key?(sym) ? row[sym] : row[key.to_s]
234
- end
225
+ def cell(row, key) = row[key.to_sym]
235
226
 
236
- # A sub-list row is identified by its PARENT and then its own key : two
227
+ # A sub-list row is identified by its parent and then its own key : two
237
228
  # entities under different parents can share a sequence, so the parent has
238
229
  # to lead or the tie is not broken at all.
239
230
  #
240
- # EVERY KEY THE PIECE IS KNOWN BY, in declaration order, for the same
231
+ # Every key the piece is known by, in declaration order, for the same
241
232
  # reason the parent leads: a part that ties is a part that breaks no tie.
242
- # This took `identified_by`, which is the SINGLE head and is nil the
233
+ # This took `identified_by`, which is the single head and is nil the
243
234
  # moment an identity has two parts — and `cell(row, nil)` calls
244
235
  # `nil.to_sym`, so a query against a composite piece did not sort wrongly,
245
236
  # it raised. A piece known by one key sorts exactly as it did.
@@ -262,7 +253,7 @@ module Hecks
262
253
  # Ports::Query::InMemory#holds? used to carry a copy each and the
263
254
  # two drifted — `none_in_state` reached only one of them, and
264
255
  # `comparable` disagreed about value objects with two numeric
265
- # members. What stays here is how a value is REACHED for this
256
+ # members. What stays here is how a value is reached for this
266
257
  # path: the registry is instance state rather than an argument.
267
258
  def holds?(clause, held, args, record: nil, domain: nil)
268
259
  QuerySpecification::Common::Comparison.holds?(
@@ -274,15 +265,79 @@ module Hecks
274
265
  value.is_a?(Symbol) ? args[value] : value
275
266
  end
276
267
 
268
+ # `boundary: false` always (C3.8 — a query's declared argument types
269
+ # name the argument for callers and generators, never a runtime
270
+ # shape checked here). A null required value-object-typed query
271
+ # argument, though, is not a runtime-shape question at all: C3.7
272
+ # says a named query's declared value-object arguments are checked
273
+ # the same way a command argument's own is, so a `nil` offered for
274
+ # a non-optional value-object-typed query attribute
275
+ # (Governance::RoleAssignment.AssignmentsForActor's `actor_id`, say)
276
+ # has to refuse — passing it through unchecked (as this used to)
277
+ # let it through as a silent, unfiltered query instead, a real
278
+ # Ruby/Rust divergence the fuzzer caught (QualityControl BUG#2).
279
+ #
280
+ # `checked_vo?` true is handled by `null_vo_argument!` directly,
281
+ # never by routing through `Value.for_attribute(argument: true)`
282
+ # into the shared `Value::Coercion#nil_argument` the command door
283
+ # (`Interpreting#coerce_declared_arguments`) still uses — that
284
+ # method builds a null value object from zero fields, which
285
+ # succeeds (silently absorbing the null via the type's own field
286
+ # defaults) whenever every field happens to have one
287
+ # (`Lease.Expired`'s `now`, a `LeaseInstant` with a `default: 0`
288
+ # field; `Account.Overdrawn`/`HighBalance`/`StrictlyAbove`/
289
+ # `AtMost`'s `floor`/`cap`, a two-defaulted-field `Money`) and only
290
+ # refuses when a field has none (`Order.CostingLessThan`'s
291
+ # `ceiling`, a defaultless `Price`) — a real query-side divergence
292
+ # from Rust, which always refuses `TypeMismatch` on an explicit
293
+ # null argument regardless of any default (QualityControl BUG#36).
294
+ # `null_vo_argument!` instead treats an explicit null exactly the
295
+ # way `Value.fields_for` already treats any other wrong-shaped
296
+ # (non-Hash, non-Value) value offered for that same attribute — a
297
+ # single-field value object auto-wraps into `{field: nil}` (whose
298
+ # own `nil` is a present key, so `Value.build`'s `apply_defaults`
299
+ # never fills it, and `check_required_fields` refuses it exactly
300
+ # as any other missing required field would); a multi-field value
301
+ # object refuses immediately with the same `value_object_shape`
302
+ # wording an ordinary wrong-shaped scalar already gets. Command
303
+ # arguments are deliberately untouched — this is a query-only
304
+ # door; `nil_argument`'s own default-absorbing fallback still
305
+ # governs a null command argument exactly as it always has.
306
+ #
307
+ # C3.8's own bare-scalar carve-out stays intact: `checked_vo?` is
308
+ # only true for a value-object-typed attribute, so a bare
309
+ # `String`/`Integer` query argument offered nil still passes
310
+ # through exactly as it always did.
277
311
  def normalize_args(aggregate, declared, args)
278
312
  declared.attributes.each_with_object(args.dup) do |attribute, normalized|
279
313
  next unless normalized.key?(attribute.name)
280
314
 
281
- normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name],
282
- boundary: false)
315
+ value = normalized[attribute.name]
316
+ normalized[attribute.name] = if checked_vo?(aggregate, attribute, value)
317
+ null_vo_argument!(aggregate, attribute)
318
+ else
319
+ Value.for_attribute(aggregate, attribute, value, boundary: false)
320
+ end
283
321
  end
284
322
  end
285
323
 
324
+ def checked_vo?(aggregate, attribute, value)
325
+ return false unless value.nil?
326
+ return false if attribute.optional? || attribute.list? || attribute.reference?
327
+ return false unless aggregate.respond_to?(:value_object)
328
+
329
+ !Value.value_object_for(aggregate, attribute.type).nil?
330
+ end
331
+
332
+ # Only ever reached when `checked_vo?` has already confirmed the
333
+ # attribute's type resolves to a real value object — see its own
334
+ # comment above for why this refuses unconditionally, never
335
+ # absorbing the null via the type's own field defaults.
336
+ def null_vo_argument!(aggregate, attribute)
337
+ value_object = Value.value_object_for(aggregate, attribute.type)
338
+ Value.build(value_object, Value.fields_for(value_object, attribute.name, nil), aggregate)
339
+ end
340
+
286
341
  def comparable(value) = QuerySpecification::Common::Comparison.comparable(value)
287
342
 
288
343
  # FieldPath.dig, not a raw `record[field]` — `record` is an Instance
@@ -49,7 +49,7 @@ module Hecks
49
49
  end
50
50
  end
51
51
 
52
- # ONE `with:` SOURCE, RESOLVED — pulled out of resolve_mapping
52
+ # One `with:` source, resolved — pulled out of resolve_mapping
53
53
  # because it is a pure function of its own arguments (a literal, a
54
54
  # binding, or a name visible in some scope), with no dependency on
55
55
  # anything else resolve_mapping's own to_h block is doing.
@@ -60,10 +60,10 @@ module Hecks
60
60
  visible = scopes.find { |scope| scope.facts.key?(source) }
61
61
  unless visible
62
62
  names = scopes.map(&:name).join(" then ")
63
- # WHAT IS VISIBLE, NAMED. A refusal that only says which name is
63
+ # **What is visible, named**. A refusal that only says which name is
64
64
  # missing sent a modeler guessing field after field
65
65
  # (`number`, `reference`…) at a fan-out row that is addressed
66
- # by ONE key — `account`, the lowercase aggregate — which
66
+ # by one key — `account`, the lowercase aggregate — which
67
67
  # nothing else in the domain spells out. The names each scope
68
68
  # actually offers are the whole diagnosis; the refusal now
69
69
  # lists them, scope by scope.
@@ -80,12 +80,12 @@ module Hecks
80
80
  # command attributes enter `with:`, while identities become `to:`.
81
81
  # `consumed` accumulates across the aggregate-identity and entity-
82
82
  # identity steps below, and refuse_unconsumed! at the end reads the
83
- # FINAL list — an ordering dependency threaded through one shared
83
+ # final list — an ordering dependency threaded through one shared
84
84
  # local. Already leans on private helpers (identity_for,
85
85
  # source_receiver_for, command_facts, refuse_unconsumed!) for every
86
- # piece that IS self-contained; what remains is the sequencing
86
+ # piece that is self-contained; what remains is the sequencing
87
87
  # itself, which further splitting would only relocate, not remove.
88
- # rubocop:disable-next Metrics/MethodLength
88
+ # rubocop:disable-next Metrics/MethodLength, Metrics/PerceivedComplexity
89
89
  def build(registry:, verb:, projected:, explicit:, passthrough: [], source_receiver: nil)
90
90
  args = projected.transform_keys(&:to_sym)
91
91
  unless explicit
@@ -99,6 +99,18 @@ module Hecks
99
99
  rescue UnknownVerb
100
100
  return args
101
101
  end
102
+ # An entity target has no shape this branch can build — an
103
+ # implicit (unprojected) `to:` is always a bare scalar, the
104
+ # aggregate's own identity alone; an entity command's receiver is
105
+ # `{aggregate:, entities:}`, which needs the entity's own
106
+ # identity too, and nothing here resolves one (the same "cannot
107
+ # invent an entity identity" limit `source_receiver_for`'s own
108
+ # comment already states). So this stays exactly the pre-BUG#6
109
+ # behavior for an entity target: no receiver is lifted, and the
110
+ # payload forwards wholesale, unchanged, same as when there is
111
+ # no `source_receiver` at all.
112
+ return args unless target.entities.empty?
113
+
102
114
  inherited_receiver = source_receiver_for(target, source_receiver)
103
115
  return inherited_receiver ? args.merge(to: inherited_receiver) : args
104
116
  end
@@ -154,6 +166,26 @@ module Hecks
154
166
  raise UnknownVerb, "reaction target #{verb.inspect} does not resolve to an aggregate" unless aggregate
155
167
 
156
168
  *entity_names, command_name = command_path.split(".")
169
+
170
+ # A port operation, reached by the same two-segment tail shape an
171
+ # entity command uses ("Head.Rest") — checked first, same order
172
+ # `Dispatcher#dispatch` already resolves a live verb in (an
173
+ # aggregate that declared both a port and an entity of the same
174
+ # name would resolve to the port there too; no domain in this
175
+ # corpus does). A port has no `entities` of its own — the
176
+ # receiver is always the aggregate itself — so `Target#entities`
177
+ # stays empty and `#command` holds the `PortOperation`, which
178
+ # answers `#creates?` (always false) the same way an ordinary
179
+ # `Command` does, letting `source_receiver_for` lift a same-
180
+ # aggregate policy's own Event.id as the operation's receiver
181
+ # exactly as it already does for a plain command.
182
+ if entity_names.one? && (port = aggregate.port(entity_names.first))
183
+ operation = port.operation(command_name)
184
+ raise UnknownVerb, "reaction target #{verb.inspect} does not resolve to a declared port operation" unless operation
185
+
186
+ return Target.new(aggregate: aggregate, entities: [], command: operation)
187
+ end
188
+
157
189
  owner = aggregate
158
190
  entities = entity_names.map do |entity_name|
159
191
  entity = owner.entities.find { |candidate| candidate.hecks_name == entity_name }
@@ -183,14 +215,49 @@ module Hecks
183
215
  private_class_method :aggregate_aliases
184
216
 
185
217
  # Event.id names the aggregate that emitted the event. It can therefore
186
- # supply only the receiver of a non-creating command on that SAME root;
218
+ # supply only the receiver of a non-creating command on that same root;
187
219
  # it cannot address another aggregate, invent an entity identity, or turn
188
220
  # a creation into a mutation. An explicit projected receiver is resolved
189
221
  # first and remains authoritative.
222
+ #
223
+ # Answers regardless of `target.entities` — the value this method hands
224
+ # back is only ever the root aggregate's own identity (`source_receiver`
225
+ # carries nothing else: `event.aggregate`/`event.id` name the emitting
226
+ # aggregate, never one of its entities). An entity target's own identity
227
+ # is resolved entirely separately, from `args` (`build`'s own `entity_
228
+ # identities` loop) — this method is never consulted for it, so
229
+ # answering for an entity target does not "invent an entity identity"
230
+ # any more than answering for a plain one invents that identity; it was
231
+ # already the one thing this method has ever supplied. `build`'s own
232
+ # explicit branch (below) already applies this correctly either way
233
+ # (`aggregate_identity ||= inherited_receiver`, entity identities read
234
+ # from `args` regardless) — the caller that actually needed a guard
235
+ # here is the implicit one, above, which has no shape to build a
236
+ # `{aggregate:, entities:}` receiver from an inherited scalar alone,
237
+ # and now guards itself for exactly that reason instead of this method
238
+ # doing it on that caller's behalf and, as a side effect, also refusing
239
+ # the explicit caller's own legitimate case — confirmed missing until
240
+ # `qa/stress_domains/waybill` (BUG#6) exercised it for the first time
241
+ # anywhere in the corpus: a saga dispatching into a nested entity's own
242
+ # command, inheriting its aggregate receiver from the event that
243
+ # triggered it.
190
244
  def source_receiver_for(target, source_receiver)
191
245
  return nil unless source_receiver
192
- return nil unless target.entities.empty?
193
- return nil if target.command.creates?
246
+ # `target.command.creates?` alone misreads every entity command —
247
+ # `Behaviour::Command#creates?`'s own comment: "a verb declared on
248
+ # an entity always acts on that piece... which means `creates?`
249
+ # answers true for every one of them" (it never sets `@references`
250
+ # the way an aggregate-level command's own `reference_to` does,
251
+ # not because it brings anything new into being). `build`'s own
252
+ # `if target.entities.empty? && target.command.creates?` guard,
253
+ # 20-odd lines below, already reads `creates?` correctly for
254
+ # exactly this reason — an entity command is never a genuine
255
+ # creation, whatever `creates?` alone answers — so this checks the
256
+ # same compound condition instead of the bare, misleading half of
257
+ # it. Left unfixed, an entity target's own receiver was refused
258
+ # here even after this method stopped refusing on `target.entities`
259
+ # alone — the second half of BUG#6's own fix.
260
+ return nil if target.entities.empty? && target.command.creates?
194
261
 
195
262
  source = source_receiver.transform_keys(&:to_sym)
196
263
  source_aggregate = source[:aggregate].to_s