hecks 1.0.2 → 1.1.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 (239) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
  15. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
  16. data/lib/hecks/adapters/driven/postgres.rb +25 -14
  17. data/lib/hecks/adapters/driven/prism.rb +11 -0
  18. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  19. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  20. data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
  21. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  22. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  23. data/lib/hecks/adapters/driven.rb +4 -0
  24. data/lib/hecks/adapters.rb +4 -0
  25. data/lib/hecks/behaviors/dsl.rb +7 -0
  26. data/lib/hecks/behaviors/expectations.rb +32 -4
  27. data/lib/hecks/behaviors/rspec.rb +2 -0
  28. data/lib/hecks/behaviors/runner.rb +4 -0
  29. data/lib/hecks/bluebook/aggregate.rb +6 -5
  30. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  31. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  32. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  33. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  34. data/lib/hecks/bluebook/assembly/marks.rb +2 -1
  35. data/lib/hecks/bluebook/assembly.rb +1 -3
  36. data/lib/hecks/bluebook/attribute.rb +6 -0
  37. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  38. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  39. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  40. data/lib/hecks/bluebook/command.rb +6 -5
  41. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  42. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  43. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
  44. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  45. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  46. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  47. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  48. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  49. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  50. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  51. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  52. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  53. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  54. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  55. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  56. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  57. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  58. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  59. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  60. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  61. data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
  62. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  63. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  64. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  65. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  66. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  67. data/lib/hecks/bluebook/entity.rb +5 -3
  68. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  69. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  70. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  71. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  72. data/lib/hecks/bluebook/expression/projection.json +12 -0
  73. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  74. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  75. data/lib/hecks/bluebook/expression.rb +1 -0
  76. data/lib/hecks/bluebook/hexagon.rb +10 -0
  77. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  78. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  79. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  80. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  81. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  82. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  83. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  84. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  85. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  86. data/lib/hecks/bluebook/model_check.rb +119 -82
  87. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  88. data/lib/hecks/bluebook/policy.rb +2 -1
  89. data/lib/hecks/bluebook/process_manager.rb +6 -0
  90. data/lib/hecks/bluebook/project_register.rb +45 -25
  91. data/lib/hecks/bluebook/query.rb +4 -0
  92. data/lib/hecks/bluebook/read_model.rb +2 -2
  93. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  94. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  95. data/lib/hecks/bluebook/translation.rb +3 -0
  96. data/lib/hecks/bluebook/value_object.rb +2 -2
  97. data/lib/hecks/codemod.rb +13 -0
  98. data/lib/hecks/doc/reference.rb +17 -11
  99. data/lib/hecks/facade/cli_runner.rb +2 -2
  100. data/lib/hecks/facade/command_request.rb +11 -11
  101. data/lib/hecks/facade/handle.rb +3 -3
  102. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  103. data/lib/hecks/facade.rb +4 -0
  104. data/lib/hecks/forms/app.rb +10 -6
  105. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  106. data/lib/hecks/forms/field_renderer.rb +14 -9
  107. data/lib/hecks/forms/field_shape.rb +7 -2
  108. data/lib/hecks/forms/html.rb +5 -2
  109. data/lib/hecks/forms/index_renderer.rb +1 -1
  110. data/lib/hecks/forms/page.rb +7 -3
  111. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  112. data/lib/hecks/forms/record_renderer.rb +22 -16
  113. data/lib/hecks/forms.rb +4 -0
  114. data/lib/hecks/fqn.rb +21 -9
  115. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  116. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  117. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  118. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  119. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  120. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  121. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  122. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  123. data/lib/hecks/fuzzing/properties.rb +41 -1065
  124. data/lib/hecks/fuzzing/replay.rb +60 -16
  125. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  126. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  127. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  128. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  129. data/lib/hecks/grammar/evolve.rb +34 -10
  130. data/lib/hecks/grammar/expression_operators.json +124 -0
  131. data/lib/hecks/grammar.rb +15 -5
  132. data/lib/hecks/ir.rb +9 -0
  133. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  134. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  135. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  136. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  138. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  140. data/lib/hecks/literal.rb +18 -8
  141. data/lib/hecks/naming.rb +6 -3
  142. data/lib/hecks/ports/access_control.rb +1 -1
  143. data/lib/hecks/ports/agent.rb +5 -3
  144. data/lib/hecks/ports/authentication.rb +3 -2
  145. data/lib/hecks/ports/authorization.rb +1 -1
  146. data/lib/hecks/ports/clock.rb +1 -1
  147. data/lib/hecks/ports/extraction.rb +5 -1
  148. data/lib/hecks/ports/identity_assignment.rb +1 -1
  149. data/lib/hecks/ports/identity_generation.rb +1 -1
  150. data/lib/hecks/ports/identity_resolution.rb +1 -1
  151. data/lib/hecks/ports/loading.rb +4 -1
  152. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  153. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  154. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  155. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  156. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  157. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  158. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  159. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
  169. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  170. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  172. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  173. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  174. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  175. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  176. data/lib/hecks/ports/persistence.rb +7 -3
  177. data/lib/hecks/ports/projection.rb +13 -5
  178. data/lib/hecks/ports/query/in_memory.rb +8 -2
  179. data/lib/hecks/ports/query.rb +1 -3
  180. data/lib/hecks/ports.rb +2 -0
  181. data/lib/hecks/projections/diagrams.rb +11 -3
  182. data/lib/hecks/projections/ir.rb +2 -0
  183. data/lib/hecks/projections/model/deviations.rb +2 -1
  184. data/lib/hecks/projections/model.rb +15 -10
  185. data/lib/hecks/projections/vocabulary.rb +1 -3
  186. data/lib/hecks/projector/cli_projector.rb +49 -29
  187. data/lib/hecks/projector/docs_projector.rb +21 -12
  188. data/lib/hecks/projector/exporter.rb +10 -1
  189. data/lib/hecks/projector/narrate_projector.rb +83 -25
  190. data/lib/hecks/projector.rb +2 -2
  191. data/lib/hecks/query_ir.rb +60 -36
  192. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  193. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  194. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  195. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  196. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  197. data/lib/hecks/query_specification/common/options.rb +17 -5
  198. data/lib/hecks/query_specification/field_path.rb +4 -4
  199. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  200. data/lib/hecks/router/namespace_installer.rb +22 -10
  201. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  202. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  203. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  204. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  205. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  206. data/lib/hecks/runtime/dispatcher.rb +41 -7
  207. data/lib/hecks/runtime/entity_element.rb +25 -6
  208. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  209. data/lib/hecks/runtime/errors.rb +28 -2
  210. data/lib/hecks/runtime/instance.rb +9 -3
  211. data/lib/hecks/runtime/interpreting.rb +29 -2
  212. data/lib/hecks/runtime/loader.rb +21 -0
  213. data/lib/hecks/runtime/outbox.rb +365 -0
  214. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  215. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  216. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  217. data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
  218. data/lib/hecks/runtime/reference_hop.rb +1 -1
  219. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  220. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  221. data/lib/hecks/runtime/registry/verification.rb +39 -2
  222. data/lib/hecks/runtime/registry.rb +21 -11
  223. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  224. data/lib/hecks/runtime/routing.rb +52 -39
  225. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  226. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  227. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  228. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  229. data/lib/hecks/runtime/value/coercion.rb +207 -48
  230. data/lib/hecks/runtime/value.rb +1 -1
  231. data/lib/hecks/runtime.rb +7 -2
  232. data/lib/hecks/storehouse.rb +15 -8
  233. data/lib/hecks/version.rb +1 -1
  234. data/lib/hecks/vocabulary.rb +1 -0
  235. data/lib/hecks.rb +17 -13
  236. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  237. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  238. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  239. metadata +14 -2
@@ -0,0 +1,848 @@
1
+ module Hecks
2
+ module Bluebook
3
+ module DSL
4
+ class BluebookBuilder
5
+ # THE self.validate_*/self.correlation_*/self.event_*/self.walk_*
6
+ # CLUSTER — every whole-chapter, cross-aggregate check `#build`
7
+ # runs once a chapter is fully assembled (reference targets, event
8
+ # shapes, correlation keys, query hops, projected fields,
9
+ # bidirectional-reference cycles), plus each one's own private
10
+ # helpers. Split out of bluebook_builder.rb (which keeps the DSL
11
+ # surface — chapter/aggregate/read_model/policy declaration,
12
+ # `#build`, and `self.build`) for the same reason
13
+ # AggregateBuilder::Sealing exists: one cohesive concern, filed by
14
+ # responsibility rather than by an arbitrary line count.
15
+ #
16
+ # `extend`ed onto BluebookBuilder rather than `include`d — every
17
+ # one of these is a pure function of its own explicit arguments
18
+ # (an assembled `Bluebook::Chapter`, an aggregate list, ...), never
19
+ # builder-instance state (no `@ivar` read anywhere in this
20
+ # module — checked before splitting it out), and every caller
21
+ # reaches these as `BluebookBuilder.validate_assembled!(...)`
22
+ # class-level calls, `#build`'s own `self.class.validate_assembled!`
23
+ # included. `extend` is `include`'s class-level counterpart: the
24
+ # same "one class, its methods, filed across files" pattern
25
+ # `Runtime::Registry` already uses (`include Verification`), just
26
+ # turning each method into a class (singleton) method instead of
27
+ # an instance method, matching what `def self.foo` already made
28
+ # every one of these before the split.
29
+ module Validation
30
+ # EVERY WHOLE-CHAPTER CHECK, IN ONE PLACE — the battery `#build`
31
+ # used to run inline, now a pure function of an assembled
32
+ # `Bluebook::Chapter` so `MetaValidator.judge_deferred!` can run
33
+ # it too, once, on a chapter whose files have ALL loaded (see
34
+ # `#build`'s own comment for why that split exists at all).
35
+ # Public, not `private_class_method`'d, for exactly that second
36
+ # caller — `MetaValidator` needs to reach this with no builder
37
+ # instance in hand, only the chapter `judge_deferred!` already
38
+ # read back out of the registry.
39
+ def validate_assembled!(bluebook)
40
+ # moved to the language: an attribute type is a reference to its Shape,
41
+ # so an undeclared value object fails reference resolution
42
+ validate_reference_value_objects!(bluebook.aggregates)
43
+ validate_correlation_keys!(bluebook.process_managers, bluebook.aggregates)
44
+ validate_no_bidirectional_references!(bluebook.aggregates)
45
+ unless MetaValidator.shadow_parsing?
46
+ validate_event_shapes!(bluebook.aggregates)
47
+ validate_with_projections!(bluebook.policies, bluebook.process_managers, bluebook.aggregates)
48
+ end
49
+
50
+ # Every hop AggregateBuilder#seal_query_field recognised and
51
+ # deferred gets checked for real here — the earliest point a
52
+ # hop CAN be checked, for exactly the reason
53
+ # validate_no_bidirectional_references! above already gives:
54
+ # `Bluebook.new` just stamped `hecks_owner` on every
55
+ # aggregate, so `Reference#resolve` finally has a chapter to
56
+ # walk. Before this line every target in the file (including
57
+ # ones declared ABOVE the aggregate doing the asking) would
58
+ # have resolved to nil.
59
+ infer_hop_query_arguments!(bluebook)
60
+ validate_query_hops!(bluebook)
61
+
62
+ # Same precondition, same reason: a `projects` declaration's
63
+ # own reference cannot resolve until every aggregate in the
64
+ # chapter is real and owner-stamped (S12, ADR 0025).
65
+ validate_projected_fields!(bluebook)
66
+ end
67
+
68
+ # AN ENTITY COMMAND MAY NOT NAME ITSELF AS ITS ROOT.
69
+ #
70
+ # That is the whole of what is left here, and it needs saying plainly
71
+ # because the sentence this used to raise — "references must target
72
+ # aggregate heads" — was never what it checked.
73
+ #
74
+ # `CommandBuilder#reference_to` sets `references` ONLY when the target's
75
+ # bare name equals the owner's ; anything else becomes a reference
76
+ # ATTRIBUTE. So on an aggregate command `references` is always a copy of
77
+ # that aggregate's own name, and looking it up in an index of aggregates
78
+ # is a TAUTOLOGY — that branch never refused anything and structurally
79
+ # could not. Verified across all eight golden chapters before deleting it.
80
+ #
81
+ # On a PIECE's command the owner is the entity, and an entity is not a
82
+ # head, so what this actually refuses is `reference_to <its own name>`
83
+ # written inside `entity do … end`. A piece is reached THROUGH its
84
+ # aggregate ; a command on one addresses the aggregate, never the piece.
85
+ #
86
+ # Reference ATTRIBUTES are the language's business now — offered as the
87
+ # head's own id and resolved as references, so `Aggregate.Reference` and
88
+ # `Command.Reference` refuse an undeclared head with no predicate at all.
89
+ def validate_reference_value_objects!(aggregates)
90
+ heads = aggregates.map(&:hecks_name)
91
+
92
+ violations = aggregates.flat_map do |aggregate|
93
+ aggregate.entities.flat_map do |entity|
94
+ entity.commands.filter_map do |command|
95
+ next unless command.references
96
+ next if heads.include?(command.references.to_s)
97
+
98
+ "#{aggregate.hecks_name}.#{entity.hecks_name}.#{command.hecks_name} names itself as its root"
99
+ end
100
+ end
101
+ end
102
+
103
+ return if violations.empty?
104
+
105
+ raise Malformed,
106
+ "an entity command is addressed through its aggregate; #{violations.uniq.join('; ')}"
107
+ end
108
+
109
+ # EVENTS ARE FIRST-CLASS BY CONVENTION, NOT BY DECLARATION (ADR
110
+ # 0025, "events and reactions" — "a domain event is a value
111
+ # object with its own attributes, not a label"). No new `event
112
+ # do ... end` construct exists to hand-author and keep in step
113
+ # with every emitting command by hand — an event's own known
114
+ # shape IS whichever command(s) declare `emits` for its name, and
115
+ # this is the ONE thing that has to hold for that convention to
116
+ # mean anything: every command that emits a given name has to
117
+ # agree on what it carries. An event is one fact; a fact does not
118
+ # carry two different truths depending on who is telling it.
119
+ #
120
+ # STRUCTURAL fields only (name/type/list/optional) — `pattern:`/
121
+ # `admits:`/`default:` are refinements ON a field, not a second
122
+ # claim about what the payload holds, so two emitting commands
123
+ # are free to differ there without actually disagreeing about
124
+ # the event's own shape.
125
+ def validate_event_shapes!(aggregates)
126
+ event_emitters(aggregates).each do |event_name, pairs|
127
+ next if pairs.size == 1
128
+
129
+ shapes = pairs.map { |(owner, command)| event_shape(command, owner_aggregate(owner, aggregates)) }.uniq
130
+ next if shapes.size == 1
131
+
132
+ named = pairs.map { |(owner, command)| "#{owner}.#{command.hecks_name}" }.sort
133
+ raise Malformed,
134
+ "#{event_name.inspect} is emitted with different shapes by #{named.join(' and ')} — " \
135
+ "an event is one fact, and every command that emits it must declare the same fields"
136
+ end
137
+ end
138
+
139
+ # THE "EXPENSIVE HALF" the ADR names: "with: { account: :account }
140
+ # projecting into a reaction that has no declared contract ...
141
+ # breaks at dispatch rather than at load." Checked here, now that
142
+ # `validate_event_shapes!` (above) guarantees at most one real
143
+ # shape per event name, and command references being first-class
144
+ # (`Naming.command_ref`) means the TARGET side is a real
145
+ # resolvable command, not a string that might be a typo.
146
+ #
147
+ # SAME-CHAPTER ONLY, ON PURPOSE — a `with:` whose source event or
148
+ # target command lives outside this chapter (an `across` policy
149
+ # reacting to another domain's event entirely) is silently left
150
+ # unchecked rather than refused: there is nothing here yet to
151
+ # check it against, and "unresolvable" is not the same claim as
152
+ # "wrong."
153
+ #
154
+ # A FOR_EACH POLICY'S SOURCE ISN'T THE EVENT AT ALL — a fan-out
155
+ # `with:`'s symbols read the QUERY ROW `for_each` answers
156
+ # (FreezeAccountsOnSuspension's own comment: "`account` is the
157
+ # key the fan-out merges for each row"), which this has no shape
158
+ # for; the SOURCE half is skipped for those, the TARGET half
159
+ # (does the dispatched command actually declare the field) still
160
+ # runs, since that half is true regardless of where the value
161
+ # came from.
162
+ def validate_with_projections!(policies, process_managers, aggregates)
163
+ lookup = command_lookup(aggregates)
164
+ heads = correlation_heads(process_managers)
165
+
166
+ policies.each do |policy|
167
+ next if policy.with_spec.to_a.empty?
168
+
169
+ source_event = policy.for_each.to_s.empty? ? policy.on_event : nil
170
+ check_with_spec!(policy.trigger_command, source_event, policy.with_spec, lookup,
171
+ "#{policy.name}'s trigger", aggregates, heads)
172
+ end
173
+
174
+ process_managers.each do |pm|
175
+ pm.handlers.each do |handler|
176
+ handler.dispatches.each do |dispatch|
177
+ next if dispatch.with_spec.to_a.empty?
178
+
179
+ check_with_spec!(dispatch.command_name, handler.event_type, dispatch.with_spec, lookup,
180
+ "#{pm.name}'s dispatch #{dispatch.command_name}", aggregates, heads, process_manager: pm)
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ # `process_manager:` is present only for a process manager's own dispatch — a
187
+ # saga leg's source symbol resolves against the CURRENT triggering
188
+ # event first, same as a policy, but falls all the way back to the
189
+ # saga's own MEMORY when the current event does not carry it
190
+ # (`SagaInterpreter#dispatch_args`, its own last `else`) — and
191
+ # memory starts as the OPENING event's payload
192
+ # (`SagaInterpreter#instance = { ..., memory: event.payload }`,
193
+ # never updated after), never the leg's own. Settlement's own
194
+ # comment names exactly this: "the credit leg reads a destination
195
+ # no event carried" — `AccountDebited` never declares `:reference`,
196
+ # only `TransferRequested` (`pm.starts_on`) does, and that is
197
+ # where the value is genuinely still coming from.
198
+ # One closed sequence of per-field checks against a fixed set of
199
+ # legal `with:` sources (target's own attributes, correlation,
200
+ # emitter identity, event/memory shape) — see the comment above
201
+ # for why each source is legal. Extracting the loop body would
202
+ # mean threading ten-plus already-resolved locals (target,
203
+ # source_shape, memory_shape, correlation, identity_sources, ...)
204
+ # into a new method for no gain: each `next`/`raise` already
205
+ # reads as its own rule at its own site.
206
+ # rubocop:disable-next Metrics/CyclomaticComplexity
207
+ # rubocop:disable-next Metrics/PerceivedComplexity
208
+ def check_with_spec!(command_ref, event_name, with_spec, lookup, label, aggregates, correlation_heads,
209
+ process_manager: nil)
210
+ target = lookup[command_ref]
211
+ source_shape = event_name && event_shape_for(event_name, aggregates)
212
+ memory_shape = process_manager && event_shape_for(process_manager.starts_on, aggregates)
213
+ correlation = process_manager&.correlates_by && process_manager.correlation_head
214
+ # A POLICY'S SOURCE ALSO CARRIES THE EMITTER'S OWN IDENTITY —
215
+ # `PolicyInterpreter#emitter_identity`, the runtime half of this.
216
+ # An entity command's event never declares its aggregate's
217
+ # identity (it arrives through `reference_to`, not an
218
+ # `attribute`), so before this a policy on `KnightCaptured` could
219
+ # not spell `with: { label: :label }` at all — "reads :label off
220
+ # KnightCaptured, which does not declare it" — and chess's
221
+ # AdvancePly grew optional, unread attributes just to survive a
222
+ # wholesale forward. Policies only: a saga leg's own source is
223
+ # `SagaInterpreter#dispatch_args`, which merges no such thing.
224
+ identity_sources = process_manager.nil? && event_name ? event_identity_heads_for(event_name, aggregates) : []
225
+
226
+ with_spec.each do |field, source|
227
+ if target && !command_declares?(
228
+ target, field, aggregates, correlation_heads
229
+ )
230
+ raise Malformed,
231
+ "#{label}'s with: names #{field.inspect}, which #{command_ref} does not declare"
232
+ end
233
+
234
+ next unless source.is_a?(::Symbol)
235
+ next if source == correlation
236
+ next if identity_sources.include?(source)
237
+ next unless source_shape || memory_shape
238
+
239
+ found = [source_shape, memory_shape].compact.any? { |shape| shape.any? { |name, *| name == source } }
240
+ next if found
241
+
242
+ raise Malformed, "#{label}'s with: reads :#{source} off #{event_name.inspect}, which does not declare it"
243
+ end
244
+ end
245
+
246
+ # A command's OWN `reference_to` (bare, no `as:`) never lands in
247
+ # `attributes` — `CommandBuilder#reference_to`'s self-reference
248
+ # branch sets `command.references` instead (S2), and mints no new
249
+ # field at all. What addresses it is not one name but the SAME
250
+ # SET `CommandInterpreter::ArgumentGate#refuse_unknown_arguments`
251
+ # already accepts at dispatch time — `:id`, the owning aggregate's
252
+ # own `identity_heads` (real corpus proof — `Account.Debit`
253
+ # dispatched everywhere as `number: ...`, `Account`'s own
254
+ # `identified_by`), AND `Naming.reference_key(command.references)`
255
+ # (real corpus proof — `FreezeAccountsOnSuspension`'s `for_each`
256
+ # fan-out, whose own comment reads "`account` is the key the
257
+ # fan-out merges for each row it answers"). Both are simultaneously
258
+ # legal there, not context-dependent alternatives, so both are
259
+ # legal here : this mirrors that gate rather than re-deriving a
260
+ # narrower rule that would refuse one of two real, already-shipped
261
+ # dispatch conventions.
262
+ def command_declares?(command, field, aggregates, correlation_heads)
263
+ return true if command.attributes.any? { |a| a.name == field }
264
+ return true if field == :id
265
+ return true if correlation_heads.include?(field)
266
+ return false unless command.references
267
+
268
+ referenced = aggregates.find { |a| a.hecks_name == command.references }
269
+ return false unless referenced
270
+
271
+ referenced.identity_heads.include?(field) || Naming.reference_key(command.references) == field
272
+ end
273
+
274
+ # THE FOURTH addressing key `ArgumentGate#refuse_unknown_arguments`
275
+ # accepts, alongside `:id`/`identity_heads`/`reference_key` — every
276
+ # saga in THIS domain's own `correlates_by` head, carried through
277
+ # every dispatch as pure passthrough (Settlement's own comment:
278
+ # "`reference:` carries the correlation forward... this is pure
279
+ # passthrough, not an addressing key"). A command declaring none of
280
+ # its attributes named this is not a gap; the correlation key rides
281
+ # through commands that never read it, same as it does at runtime.
282
+ def correlation_heads(process_managers)
283
+ process_managers.filter_map { |pm| pm.correlates_by && pm.correlation_head }
284
+ end
285
+
286
+ # Every command this chapter declares, an aggregate's own AND
287
+ # every entity nested inside one, paired with a name for what
288
+ # declares it — shared by `validate_event_shapes!` and
289
+ # `validate_with_projections!`'s own command lookup, the same
290
+ # reach `HecksagonBuilder#commands_in` needs one level up (S8).
291
+ def each_command(aggregates)
292
+ return enum_for(:each_command, aggregates) unless block_given?
293
+
294
+ aggregates.each do |aggregate|
295
+ aggregate.commands.each { |command| yield aggregate.hecks_name, command }
296
+ aggregate.entities.each do |entity|
297
+ entity.commands.each { |command| yield "#{aggregate.hecks_name}.#{entity.hecks_name}", command }
298
+ end
299
+ end
300
+ end
301
+
302
+ # NOT MEMOISED — this used to be `@event_emitters ||=` on the
303
+ # builder instance, which is safe for a one-file chapter but
304
+ # wrong for one split across several: the FIRST file's build()
305
+ # call would compute and cache it from whatever `@aggregates`
306
+ # held at that moment, and every later file's own validation
307
+ # would keep reading that same stale snapshot, silently missing
308
+ # any command a later file adds. Recomputed fresh every call
309
+ # instead — this walks the whole chapter once per `#build`, not
310
+ # a hot path worth memoising at that cost.
311
+ def event_emitters(aggregates)
312
+ each_command(aggregates).with_object(Hash.new { |h, k| h[k] = [] }) do |(owner, command), index|
313
+ command.emits.each { |event_name| index[event_name] << [owner, command] }
314
+ end
315
+ end
316
+
317
+ # STRUCTURAL, NOT NOMINAL. Two commands on two different
318
+ # aggregates that both `emits "SameEvent"` are free to type a
319
+ # field through two DIFFERENT, locally-scoped wrapper value
320
+ # objects (e.g. one aggregate's own `value: SomeText` vs
321
+ # another's `value: OtherText`, exactly the per-aggregate "own
322
+ # text VO" convention every aggregate in this grammar already
323
+ # follows for everything from `RuleText` to `FieldRef`) without
324
+ # actually disagreeing about the event's shape — comparing
325
+ # `a.type` by NAME would flag that as a violation for no real
326
+ # reason: an event is one fact, and two isomorphic wrapper types
327
+ # tell an identical one. So a value-object type is unwrapped to
328
+ # its OWN attribute shape (recursively — a wrapper could itself
329
+ # wrap another) before comparing, and only a primitive type
330
+ # (nothing left to unwrap) or two VOs that truly differ once
331
+ # unwrapped still counts as a real mismatch. `owner` carries the
332
+ # type's `value_object` lookup — a command's own attributes only
333
+ # know their type's NAME, never the aggregate that declared it,
334
+ # and two sibling aggregates in one chapter each keep a
335
+ # same-named VO private to themselves, so the unwrap has to ask
336
+ # the SAME aggregate the field's own command belongs to, never a
337
+ # neighbor's.
338
+ def event_shape(command, owner)
339
+ command.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s), a.list?, a.optional?] }.sort
340
+ end
341
+
342
+ def unwrap_shape(owner, type_name, seen = [])
343
+ return type_name if owner.nil? # owner couldn't be resolved -- compare by name, same as before this unwrap existed
344
+ return type_name if Attribute::PRIMITIVES.include?(type_name)
345
+ # a self-referential VO bottoms out on its own name, not an infinite unwrap
346
+ return type_name if seen.include?(type_name)
347
+
348
+ shape = owner.value_object(type_name)
349
+ return type_name unless shape # not this owner's own VO (a reference type, say) -- nothing further to unwrap
350
+
351
+ shape.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s, seen + [type_name]), a.list?, a.optional?] }.sort
352
+ end
353
+
354
+ # `owner` (from `each_command`) is a plain STRING — the aggregate's
355
+ # `hecks_name` alone, or `"Aggregate.Entity"` for an entity's own
356
+ # command. Either way the VALUE OBJECTS a command's fields can be
357
+ # typed with are the AGGREGATE's own (`Entity` carries no
358
+ # `value_object` lookup of its own — the whole rest of this file
359
+ # already resolves hop/type lookups only at the aggregate level,
360
+ # e.g. `validate_hop_tail!`'s `target.value_object(type)`), so only
361
+ # the first segment ever matters here.
362
+ def owner_aggregate(owner, aggregates)
363
+ aggregates.find { |a| a.hecks_name == owner.to_s.split(".").first }
364
+ end
365
+
366
+ def event_shape_for(event_name, aggregates)
367
+ pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
368
+ return nil if pairs.empty?
369
+
370
+ owner_name, command = pairs.first
371
+ event_shape(command, owner_aggregate(owner_name, aggregates))
372
+ end
373
+
374
+ # The identity heads of the aggregate that emits `event_name` — an
375
+ # entity's event is stamped with its OWNING aggregate's identity
376
+ # (`Event#id` is the parent's), so an owner spelled "Game.Knight"
377
+ # answers Game's heads.
378
+ def event_identity_heads_for(event_name, aggregates)
379
+ pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
380
+ return [] if pairs.empty?
381
+
382
+ owner_name, = pairs.first
383
+ aggregate = owner_aggregate(owner_name, aggregates)
384
+ return [] unless aggregate
385
+
386
+ heads = aggregate.identity_heads.map(&:to_sym)
387
+ # AN ENTITY'S EVENT ALSO CARRIES THE PIECE'S OWN IDENTITY — the
388
+ # args a piece was addressed by are the args its event announces
389
+ # (`Emission#emit`: `payload: args`), so `id`-shaped heads are
390
+ # genuinely there at runtime even though no `attribute` line on
391
+ # the entity command declares them.
392
+ entity_names = owner_name.to_s.split(".").drop(1)
393
+ entity = entity_names.reduce(aggregate) { |owner, name| owner&.entities&.find { |e| e.hecks_name == name } }
394
+ heads + (entity ? entity.identity_heads.map(&:to_sym) : [])
395
+ end
396
+
397
+ def command_lookup(aggregates)
398
+ each_command(aggregates).with_object({}) do |(owner, command), index|
399
+ index["#{owner}.#{command.hecks_name}"] = command
400
+ end
401
+ end
402
+
403
+ # A REFERENCE RING IS NOT A MODELLING CHOICE, IT IS A MISSING ONE
404
+ # — a DDD aggregate is a consistency boundary precisely because
405
+ # something outside it can only ever point IN, by id, never the
406
+ # other way. A caller must be able to reason about one aggregate
407
+ # alone ; a ring back to where it started means no aggregate in
408
+ # it is a boundary anyone can reason about without the rest of
409
+ # the ring, and the whole ring is really one aggregate wearing
410
+ # several names.
411
+ #
412
+ # Checked at the bluebook level, not inside `AggregateBuilder`
413
+ # itself, because seeing a cycle needs every end declared — an
414
+ # aggregate finishes building long before it can know whether
415
+ # some later aggregate in the same file points back at it.
416
+ #
417
+ # ACYCLIC WITHIN A CHAPTER (ADR 0025, "References") — widened
418
+ # from the direct pair (A -> B -> A) this used to catch alone to
419
+ # any ring, however long (A -> B -> C -> A), the same DFS
420
+ # coloring a reference graph needs for any cycle. A cross-chapter
421
+ # reference is UNREACHABLE here rather than unchecked:
422
+ # `Reference#resolve` is scoped to its own chapter by
423
+ # construction, so a target this chapter never declares is a
424
+ # dangling name, not an edge — `edges.key?` below is what keeps
425
+ # the walk from ever leaving this chapter's own aggregates.
426
+ # Self-reference stays legal (`parent.parent.name` for a
427
+ # hierarchy is real and safe) — excluded the same way the
428
+ # direct-pair check already excluded it.
429
+ def validate_no_bidirectional_references!(aggregates)
430
+ edges = aggregates.to_h do |aggregate|
431
+ [aggregate.hecks_name, aggregate.reference_targets.uniq.reject { |target| target == aggregate.hecks_name }]
432
+ end
433
+
434
+ cycle = find_reference_cycle(edges)
435
+ return unless cycle
436
+
437
+ ring = "#{cycle.join(' -> ')} -> #{cycle.first}"
438
+ raise Malformed,
439
+ "reference cycle: #{ring} — an aggregate points at another by id, and a " \
440
+ "ring back to where it started means no aggregate in it is a boundary " \
441
+ "anyone can reason about alone ; break the ring, or let one side be found " \
442
+ "through a query instead of a reference pointing back"
443
+ end
444
+
445
+ # Plain DFS with a visiting/done coloring, over the reference
446
+ # graph THIS chapter's own aggregates declare. Returns the ring
447
+ # itself (in the order it closes), or nil.
448
+ def find_reference_cycle(edges)
449
+ state = {}
450
+
451
+ edges.each_key do |start|
452
+ cycle = reference_cycle_from(start, edges, state, [])
453
+ return cycle if cycle
454
+ end
455
+
456
+ nil
457
+ end
458
+
459
+ def reference_cycle_from(node, edges, state, path)
460
+ return nil if state[node] == :done
461
+ return path[path.index(node)..] if state[node] == :visiting
462
+
463
+ state[node] = :visiting
464
+ path.push(node)
465
+
466
+ edges[node].each do |target|
467
+ next unless edges.key?(target) # a name this chapter never declares is dangling, not an edge
468
+
469
+ found = reference_cycle_from(target, edges, state, path)
470
+ return found if found
471
+ end
472
+
473
+ path.pop
474
+ state[node] = :done
475
+ nil
476
+ end
477
+
478
+ # THE OTHER HALF OF A HOP — AggregateBuilder#seal_query_field
479
+ # recognised the HEAD of a dotted where-field that names one of
480
+ # its own references and deferred it here, unable to check
481
+ # further: it cannot yet resolve what the reference points AT.
482
+ # This runs once every aggregate exists in one chapter, so it
483
+ # can.
484
+ #
485
+ # Only WHERE clauses ever reach here — a hop on ORDER BY is
486
+ # refused outright, immediately, back in seal_query_field
487
+ # itself (that answer never needed the target's shape).
488
+ #
489
+ # AN ENTITY'S OWN QUERIES DID reach `EntityBuilder#reference_to`
490
+ # (added after this comment first claimed otherwise — S9, ADR
491
+ # 0025) without ever reaching HERE: tier-1 sealing
492
+ # (`AggregateBuilder#query_surfaces`) already recognises a hop
493
+ # on an entity's own field and DEFERS it exactly like an
494
+ # aggregate's, but nothing ever walked entity queries at tier 2
495
+ # to check the deferral — a bad hop, or even a well-formed one,
496
+ # built silently and then matched nothing at runtime
497
+ # (`QueryInterpreter#entity_rows` reads an element's fields by
498
+ # literal hash key, never follows a reference). Refused outright
499
+ # here instead of taught to follow the hop for real: no corpus
500
+ # member needs an entity query to cross a reference yet, and a
501
+ # named refusal beats a runtime that resolves nothing while
502
+ # looking like it might.
503
+ def validate_query_hops!(bluebook)
504
+ bluebook.aggregates.each do |aggregate|
505
+ aggregate.queries.each do |query|
506
+ query.wheres.each do |clause|
507
+ next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
508
+
509
+ validate_hop_clause!(aggregate, query, clause)
510
+ end
511
+ end
512
+
513
+ aggregate.entities.each { |entity| refuse_entity_query_hops!(aggregate, entity) }
514
+ end
515
+ end
516
+
517
+ # The chapter-wide half of AggregateBuilder's local query-argument
518
+ # inference. A hop cannot resolve while its aggregate is still being
519
+ # built; here every Reference has an owner and target, so a symbolic
520
+ # comparison can inherit the type of the scalar it compares without a
521
+ # duplicate query-local declaration.
522
+ def infer_hop_query_arguments!(bluebook)
523
+ bluebook.aggregates.each do |aggregate|
524
+ aggregate.queries.each do |query|
525
+ query.wheres.each do |clause|
526
+ name = clause.value
527
+ next unless name.is_a?(Symbol)
528
+ next if query.attribute(name)
529
+ next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
530
+
531
+ plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes)
532
+ next if plan.refusal || plan.hops.empty?
533
+
534
+ leaf = inferred_hop_leaf(name, plan)
535
+ next unless leaf
536
+
537
+ query.attributes << leaf
538
+ end
539
+ end
540
+ end
541
+ end
542
+
543
+ # THE LEAF `infer_hop_query_arguments!` INFERS for one resolved
544
+ # hop plan — a pure function of `plan` and the symbolic `name`
545
+ # it is naming, pulled out because it is a self-contained
546
+ # computation with no dependency on the enclosing loop's own
547
+ # iteration state (it neither reads nor mutates anything about
548
+ # `bluebook`/`aggregate`/`query` beyond what `plan` already
549
+ # carries).
550
+ def inferred_hop_leaf(name, plan)
551
+ target = plan.hops.last.target
552
+ head, *nested = plan.tail.to_s.split(".")
553
+ if nested.empty? && target.lifecycle&.field.to_s == head
554
+ Attribute.new(name: name, type: String)
555
+ else
556
+ root = target.attributes.find { |candidate| candidate.name.to_s == head }
557
+ found = root && QuerySpecification::FieldPath.leaf_attribute(root, nested) do |type|
558
+ target.value_object(type)
559
+ end
560
+ found && Attribute.new(name: name, type: found.type, list: found.list?)
561
+ end
562
+ end
563
+
564
+ def refuse_entity_query_hops!(aggregate, entity)
565
+ entity.queries.each do |query|
566
+ query.wheres.each do |clause|
567
+ next unless QuerySpecification::HopPath.hop_head?(clause.field, entity.attributes)
568
+
569
+ raise Malformed,
570
+ "#{aggregate.hecks_name}::#{entity.hecks_name}.#{query.hecks_name} asks about " \
571
+ "#{clause.field}, which hops through #{entity.hecks_name}'s own reference — " \
572
+ "an entity query does not follow a hop the way an aggregate's own does; ask " \
573
+ "through the aggregate's own query instead, or open the target directly"
574
+ end
575
+ end
576
+ end
577
+
578
+ def validate_hop_clause!(aggregate, query, clause)
579
+ plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes)
580
+
581
+ case plan.refusal
582
+ when :unresolvable
583
+ # HopPath.plan pushes even an unresolved hop onto `hops`
584
+ # before reporting this, specifically so `target_name` —
585
+ # real, known at declaration, independent of whether
586
+ # `resolve` succeeded — is always here to name.
587
+ raise Malformed,
588
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
589
+ "which hops to #{plan.hops.last.target_name}, which this chapter never " \
590
+ "declares — a hop into an aggregate this chapter cannot see resolves to " \
591
+ "nothing, and a where that resolves to nothing matches nothing and " \
592
+ "refuses nothing"
593
+ when :too_deep
594
+ raise Malformed,
595
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
596
+ "whose hop chain reaches #{QuerySpecification::HopPath::MAX_HOPS} " \
597
+ "references deep without landing — a chain this long is refused as a " \
598
+ "likely mistake, not a structural limit"
599
+ end
600
+
601
+ target = plan.hops.last.target
602
+ validate_hop_tail!(aggregate, query, clause, target, plan.tail)
603
+ end
604
+
605
+ # The same three-way answer seal_query_field gives for its OWN
606
+ # aggregate's fields — landing on a real scalar (fine), landing
607
+ # on a value object (refused by name), or naming nothing at all
608
+ # (refused by name) — asked instead of the hop's TARGET aggregate,
609
+ # since that is whose shape the tail actually has to answer for.
610
+ def validate_hop_tail!(aggregate, query, clause, target, tail)
611
+ name, *nested = tail.to_s.split(".")
612
+ attribute = target.attributes.find { |candidate| candidate.name.to_s == name }
613
+ return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if
614
+ nested.empty? && (attribute || target.lifecycle&.field.to_s == name)
615
+ return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if
616
+ nested.any? && attribute &&
617
+ QuerySpecification::FieldPath.scalar_leaf?(attribute, nested) { |type| target.value_object(type) }
618
+
619
+ if nested.any? && attribute &&
620
+ !QuerySpecification::FieldPath.leaf_attribute(attribute, nested) { |type| target.value_object(type) }.nil?
621
+ raise Malformed,
622
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
623
+ "which hops to #{target.hecks_name} and then asks about #{tail}, which " \
624
+ "lands on a value object, not a scalar — a dotted query path ends on a " \
625
+ "scalar member, or the engines answer it differently"
626
+ end
627
+
628
+ raise Malformed,
629
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
630
+ "which hops to #{target.hecks_name} and then asks about #{tail}, which " \
631
+ "#{target.hecks_name} never declares — a query over a field that does " \
632
+ "not exist matches nothing and refuses nothing"
633
+ end
634
+
635
+ # A WHERE hop with an ordered comparator is legitimate ("client
636
+ # whose balance > 500") — AggregateBuilder#seal_ordered_comparator
637
+ # already deferred this exact check for the same reason every
638
+ # other hop check is deferred, and this is where it gets asked,
639
+ # against the hop's TARGET instead of the querying aggregate.
640
+ def validate_hop_comparator!(aggregate, query, clause, target, attribute, nested)
641
+ return unless AggregateBuilder::ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym)
642
+ return if attribute &&
643
+ QuerySpecification::FieldPath.numeric?(attribute, nested) { |type| target.value_object(type) }
644
+
645
+ held = attribute ? "holds no number" : "is the lifecycle field, which holds text"
646
+ raise Malformed,
647
+ "#{aggregate.hecks_name}.#{query.hecks_name} compares #{clause.field} with " \
648
+ "#{clause.op} after hopping to #{target.hecks_name}, but the field it lands " \
649
+ "on #{held} — an ordered comparison needs a numeric field, and over " \
650
+ "anything else the adapters answer differently or not at all"
651
+ end
652
+
653
+ # THE TARGET HALF of `projects` validation (S12, ADR 0025) —
654
+ # `AggregateBuilder#seal_projected_fields` already checked the
655
+ # LOCAL half at declare time (the reference names a real
656
+ # `reference_to` on THIS aggregate); this checks the reference
657
+ # actually resolves to a real aggregate in this chapter, and
658
+ # that aggregate really declares `remote_field` as a scalar.
659
+ #
660
+ # Reuses `QuerySpecification::HopPath` rather than re-deriving
661
+ # hop resolution a second way — `"reference/remote_field"` is
662
+ # the same single-hop shape a query's own `/`-spelled hop
663
+ # resolves, even though `projects`'s own DSL spelling is dotted
664
+ # (`from: :"customer.status"`): two constructs, two spellings,
665
+ # one resolution primitive. A single hop can never reach
666
+ # HopPath::MAX_HOPS, so :too_deep is structurally unreachable
667
+ # here and is not special-cased.
668
+ def validate_projected_fields!(bluebook)
669
+ bluebook.aggregates.each do |aggregate|
670
+ aggregate.projected_fields.each { |field| validate_projected_field!(aggregate, field) }
671
+ end
672
+ end
673
+
674
+ # A linear decision tree of validation rules over ONE resolved
675
+ # hop plan, each already explained by its own comment above
676
+ # (the lifecycle fallback, the chained-projection fallback, the
677
+ # final scalar check) — a fixed, closed sequence "resolve, then
678
+ # check each way this could legitimately or illegitimately
679
+ # land," not several unrelated concerns. Splitting it would
680
+ # scatter `plan`/`target`/`remote_attribute` across new methods
681
+ # that would each need most of them anyway.
682
+ # rubocop:disable-next Metrics/AbcSize
683
+ def validate_projected_field!(aggregate, field)
684
+ plan = QuerySpecification::HopPath.plan("#{field.reference}/#{field.remote_field}", aggregate.attributes)
685
+
686
+ if plan.refusal == :unresolvable
687
+ raise Malformed,
688
+ "#{aggregate.hecks_name}.projects :#{field.name} reads through :#{field.reference}, " \
689
+ "which hops to #{plan.hops.last.target_name}, which this chapter never declares — " \
690
+ "a projection through an aggregate this chapter cannot see resolves to nothing"
691
+ end
692
+
693
+ target = plan.hops.last.target
694
+ remote_attribute = target.attributes.find { |candidate| candidate.name.to_s == plan.tail }
695
+
696
+ # THE WORKED EXAMPLE ITSELF (ADR 0025) reads through a
697
+ # LIFECYCLE field — banking's Customer.status is `lifecycle
698
+ # :status`, never a plain `attribute` — the same fallback
699
+ # validate_hop_tail! already gives a query's own hop tail. A
700
+ # lifecycle field is always a plain string by construction ;
701
+ # nothing further to check once it matches by name.
702
+ return if remote_attribute.nil? && target.lifecycle&.field.to_s == plan.tail
703
+
704
+ # A PROJECTION MAY CHAIN THROUGH ANOTHER PROJECTION (S12, ADR
705
+ # 0025's own boundary rule, followed through) — `target`'s
706
+ # OWN projected fields live in `projected_fields`, a
707
+ # separate list from `attributes`, so a match there is
708
+ # invisible to the check above even though it names a real,
709
+ # always-current, stored field. `Transfer.projects
710
+ # :source_customer_status, from: :"source.customer_status"`
711
+ # reads Account's own already-projected `customer_status`
712
+ # this way — Account is one hop from Customer, Transfer is
713
+ # one hop from Account, and neither aggregate needs to know
714
+ # about the other's target two hops away. A projected
715
+ # field's remote value is always a scalar by construction
716
+ # (`RebuildSweep.remote_value` never copies a reference, a
717
+ # value object, or a list), so nothing further to check once
718
+ # it matches by name — same reasoning the lifecycle
719
+ # fallback just above already applies.
720
+ return if remote_attribute.nil? && target.projected_fields.any? { |f| f.name.to_s == plan.tail }
721
+
722
+ unless remote_attribute
723
+ raise Malformed,
724
+ "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \
725
+ "#{plan.tail.inspect}, which #{target.hecks_name} never declares"
726
+ end
727
+
728
+ return if projectable_scalar?(target, remote_attribute)
729
+
730
+ raise Malformed,
731
+ "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \
732
+ "#{plan.tail.inspect}, which is not a scalar — a projected field copies a single " \
733
+ "value, never a reference, a value object, or a list"
734
+ end
735
+
736
+ def projectable_scalar?(target, attribute)
737
+ !attribute.list? && !attribute.reference? && target.value_object(attribute.type).nil?
738
+ end
739
+
740
+ # `correlates_by` NAMES A SCALAR, NOW CHECKED RATHER THAN TRUSTED.
741
+ #
742
+ # ProcessManagerBuilder#validate! already refuses a bare, undotted
743
+ # spelling — a SYNTACTIC guarantee that the declaration cannot leave
744
+ # the question open. It cannot go further: a process manager is built
745
+ # in isolation, before this chapter's aggregates exist to check
746
+ # against. Here, with the whole document assembled, the dotted path
747
+ # is walked for real — against whichever command actually emits an
748
+ # event this process manager reacts to — so a path that still lands
749
+ # on a value object is refused before the runtime ever has to decide
750
+ # what a non-scalar correlation key even means: a saga keys off this
751
+ # value directly, and a value object carries no guaranteed-stable
752
+ # identity to key on the way a scalar does.
753
+ #
754
+ # A command that does not declare the path's first segment at all is
755
+ # silently skipped, not refused — correlation has two other fallback
756
+ # tiers below the payload dig (a correlation stamp, then the emitting
757
+ # aggregate's own reference key; saga_interpreter/correlation.rb), so
758
+ # an absent field is not this check's business. Only a field that
759
+ # resolves, and resolves to something other than a scalar, is.
760
+ def validate_correlation_keys!(process_managers, aggregates)
761
+ process_managers.each do |pm|
762
+ next unless pm.correlates_by
763
+
764
+ reason = correlation_key_violation(pm, aggregates)
765
+ next unless reason
766
+
767
+ raise ProcessManagerBuilder::InvalidProcessManager,
768
+ "#{pm.name} correlates_by #{pm.correlates_by.inspect}, but #{reason}"
769
+ end
770
+ end
771
+
772
+ def correlation_key_violation(process_manager, aggregates)
773
+ head, *rest = process_manager.correlates_by.to_s.split(".")
774
+ events = reacted_events(process_manager)
775
+
776
+ emitting_commands(events, aggregates).each do |owner, command|
777
+ attribute = command.attributes.find { |a| a.name == head.to_sym }
778
+ next unless attribute
779
+
780
+ reason = list_or_scalar_violation(owner, attribute, rest)
781
+ return reason if reason
782
+ end
783
+
784
+ nil
785
+ end
786
+
787
+ def reacted_events(process_manager)
788
+ ([process_manager.starts_on, process_manager.ends_on] + process_manager.handlers.map(&:event_type))
789
+ .compact
790
+ .reject { |event| event == ProcessManager::REFUSED }
791
+ .map { |event| event.to_s.split("::").last }
792
+ .uniq
793
+ end
794
+
795
+ def emitting_commands(events, aggregates)
796
+ aggregates.flat_map do |aggregate|
797
+ commands = aggregate.commands + aggregate.entities.flat_map(&:commands)
798
+ commands.select { |command| command.emits.map(&:to_s).intersect?(events) }
799
+ .map { |command| [aggregate, command] }
800
+ end
801
+ end
802
+
803
+ def list_or_scalar_violation(owner, attribute, segments)
804
+ if attribute.list?
805
+ return "#{attribute.name} is a list — a correlation key must name one instance's own field, " \
806
+ "not a whole collection"
807
+ end
808
+
809
+ walk_scalar(owner, attribute.type.to_s, segments)
810
+ end
811
+
812
+ # Walks the remaining dotted segments through nested value objects.
813
+ # `type_name` starts as the head attribute's own declared type ; each
814
+ # step either bottoms out at a real scalar (nil — no violation) or
815
+ # names why it cannot: still a value object with no more path left,
816
+ # a value object this domain never declared, a field that value
817
+ # object does not have, or a segment left over after already
818
+ # reaching a scalar.
819
+ def walk_scalar(owner, type_name, segments)
820
+ if segments.empty?
821
+ return nil if Attribute::PRIMITIVES.include?(type_name)
822
+
823
+ return "#{type_name} is a value object, not a scalar — name one of its own fields, " \
824
+ "e.g. #{type_name.downcase}.value"
825
+ end
826
+
827
+ if Attribute::PRIMITIVES.include?(type_name)
828
+ return "#{type_name} is already a scalar — #{segments.join('.')} has nothing left to reach"
829
+ end
830
+
831
+ shape = owner.value_object(type_name)
832
+ return "#{type_name} is not a value object this domain declares" unless shape
833
+
834
+ segment, *rest = segments
835
+ attribute = shape.attributes.find { |a| a.name == segment.to_sym }
836
+ return "#{type_name} has no field #{segment.inspect}" unless attribute
837
+ if attribute.list?
838
+ return "#{type_name}.#{segment} is a list — a correlation key must name one instance's own field, " \
839
+ "not a whole collection"
840
+ end
841
+
842
+ walk_scalar(owner, attribute.type.to_s, rest)
843
+ end
844
+ end
845
+ end
846
+ end
847
+ end
848
+ end