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
@@ -1,11 +1,21 @@
1
1
  require_relative "word_gate"
2
+ require_relative "bluebook_builder/validation"
2
3
  module Hecks
3
4
  module Bluebook
4
5
  module DSL
6
+ # The `Hecks.bluebook "Name" do ... end` receiver — the chapter-level
7
+ # builder collecting every `aggregate`/`read_model`/`policy`/
8
+ # `process_manager` a chapter declares, plus the chapter-wide named-
9
+ # given pools those thread down into (see `#aggregate_impl`'s own
10
+ # comment). `.build` reuses the SAME open builder instance across
11
+ # several files sharing one chapter name (`self.build`'s own comment),
12
+ # so a chapter split across files accumulates rather than each file
13
+ # silently replacing the last.
5
14
  class BluebookBuilder
6
- GRAMMAR_CONTEXT = "Bluebook"
15
+ GRAMMAR_CONTEXT = "Bluebook".freeze
7
16
 
8
17
  include WordGate
18
+ extend Validation
9
19
 
10
20
  attr_reader :classification
11
21
 
@@ -44,7 +54,8 @@ module Hecks
44
54
  def adopt_version(version)
45
55
  return if version.nil?
46
56
  if @version && @version.to_s != version.to_s
47
- raise Malformed, "#{@name} declares both version #{@version.inspect} and #{version.inspect}"
57
+ raise Malformed,
58
+ "#{@name} declares both version #{@version.inspect} and #{version.inspect}"
48
59
  end
49
60
 
50
61
  @version = version
@@ -91,11 +102,11 @@ module Hecks
91
102
  # dispatch (slice 4c). Bootstrap-reachable (every core/attached
92
103
  # chapter's own top-level shape is written with it), so also
93
104
  # named in GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.
94
- def aggregate_impl(name, &block)
105
+ def aggregate_impl(name, &)
95
106
  @aggregates << AggregateBuilder.build(name, chapter_named_givens: @chapter_named_givens,
96
107
  chapter_pending_givens: @chapter_pending_givens,
97
108
  chapter_entity_named_givens: @chapter_entity_named_givens,
98
- chapter_entity_pending_givens: @chapter_entity_pending_givens, &block)
109
+ chapter_entity_pending_givens: @chapter_entity_pending_givens, &)
99
110
  end
100
111
 
101
112
  # `read_model` is the word (ADR 0025 reverts `report` — the IR
@@ -106,25 +117,25 @@ module Hecks
106
117
  # bridge) for the same reason `has_many` does — frozen era text
107
118
  # that used it must keep booting; live source refuses it, naming
108
119
  # the replacement.
109
- def read_model(name, &block)
120
+ def read_model(name, &)
110
121
  # A read model gathers heads from SEVERAL aggregates, so no single head
111
122
  # declares it — the chapter does. Its owner is stamped in `build`, where
112
123
  # the chapter namespace exists.
113
- @read_models << ReadModelBuilder.build(name, &block)
124
+ @read_models << ReadModelBuilder.build(name, &)
114
125
  end
115
126
 
116
- def report(name, &block)
117
- return read_model(name, &block) if MetaValidator.shadow_parsing?
127
+ def report(name, &)
128
+ return read_model(name, &) if MetaValidator.shadow_parsing?
118
129
 
119
130
  raise Malformed, "report is gone — read_model is the word now"
120
131
  end
121
132
 
122
- def policy(name, &block)
123
- @policies << PolicyBuilder.build(name, &block)
133
+ def policy(name, &)
134
+ @policies << PolicyBuilder.build(name, &)
124
135
  end
125
136
 
126
- def process_manager(name, &block)
127
- @process_managers << ProcessManagerBuilder.build(name, &block)
137
+ def process_manager(name, &)
138
+ @process_managers << ProcessManagerBuilder.build(name, &)
128
139
  end
129
140
 
130
141
  def build
@@ -203,6 +214,7 @@ module Hecks
203
214
  entry[:placeholder].description = resolved.description
204
215
  entry[:placeholder].canonical = resolved.canonical
205
216
  entry[:placeholder].predicate = resolved.predicate
217
+ entry[:placeholder].ast = resolved.ast
206
218
  end
207
219
  @chapter_pending_givens.clear
208
220
  end
@@ -244,6 +256,7 @@ module Hecks
244
256
  entry[:placeholder].description = resolved.description
245
257
  entry[:placeholder].canonical = resolved.canonical
246
258
  entry[:placeholder].predicate = resolved.predicate
259
+ entry[:placeholder].ast = resolved.ast
247
260
  end
248
261
  @chapter_entity_pending_givens.clear
249
262
  end
@@ -276,778 +289,6 @@ module Hecks
276
289
  end
277
290
  private :resolve_pending_chapter_entity_given
278
291
 
279
- # EVERY WHOLE-CHAPTER CHECK, IN ONE PLACE — the battery `#build`
280
- # used to run inline, now a pure function of an assembled
281
- # `Bluebook::Chapter` so `MetaValidator.judge_deferred!` can run
282
- # it too, once, on a chapter whose files have ALL loaded (see
283
- # `#build`'s own comment for why that split exists at all).
284
- # Public, not `private_class_method`'d, for exactly that second
285
- # caller — `MetaValidator` needs to reach this with no builder
286
- # instance in hand, only the chapter `judge_deferred!` already
287
- # read back out of the registry.
288
- def self.validate_assembled!(bluebook)
289
- # moved to the language: an attribute type is a reference to its Shape,
290
- # so an undeclared value object fails reference resolution
291
- validate_reference_value_objects!(bluebook.aggregates)
292
- validate_correlation_keys!(bluebook.process_managers, bluebook.aggregates)
293
- validate_no_bidirectional_references!(bluebook.aggregates)
294
- unless MetaValidator.shadow_parsing?
295
- validate_event_shapes!(bluebook.aggregates)
296
- validate_with_projections!(bluebook.policies, bluebook.process_managers, bluebook.aggregates)
297
- end
298
-
299
- # Every hop AggregateBuilder#seal_query_field recognised and
300
- # deferred gets checked for real here — the earliest point a
301
- # hop CAN be checked, for exactly the reason
302
- # validate_no_bidirectional_references! above already gives:
303
- # `Bluebook.new` just stamped `hecks_owner` on every
304
- # aggregate, so `Reference#resolve` finally has a chapter to
305
- # walk. Before this line every target in the file (including
306
- # ones declared ABOVE the aggregate doing the asking) would
307
- # have resolved to nil.
308
- infer_hop_query_arguments!(bluebook)
309
- validate_query_hops!(bluebook)
310
-
311
- # Same precondition, same reason: a `projects` declaration's
312
- # own reference cannot resolve until every aggregate in the
313
- # chapter is real and owner-stamped (S12, ADR 0025).
314
- validate_projected_fields!(bluebook)
315
- end
316
-
317
- # AN ENTITY COMMAND MAY NOT NAME ITSELF AS ITS ROOT.
318
- #
319
- # That is the whole of what is left here, and it needs saying plainly
320
- # because the sentence this used to raise — "references must target
321
- # aggregate heads" — was never what it checked.
322
- #
323
- # `CommandBuilder#reference_to` sets `references` ONLY when the target's
324
- # bare name equals the owner's ; anything else becomes a reference
325
- # ATTRIBUTE. So on an aggregate command `references` is always a copy of
326
- # that aggregate's own name, and looking it up in an index of aggregates
327
- # is a TAUTOLOGY — that branch never refused anything and structurally
328
- # could not. Verified across all eight golden chapters before deleting it.
329
- #
330
- # On a PIECE's command the owner is the entity, and an entity is not a
331
- # head, so what this actually refuses is `reference_to <its own name>`
332
- # written inside `entity do … end`. A piece is reached THROUGH its
333
- # aggregate ; a command on one addresses the aggregate, never the piece.
334
- #
335
- # Reference ATTRIBUTES are the language's business now — offered as the
336
- # head's own id and resolved as references, so `Aggregate.Reference` and
337
- # `Command.Reference` refuse an undeclared head with no predicate at all.
338
- def self.validate_reference_value_objects!(aggregates)
339
- heads = aggregates.map(&:hecks_name)
340
-
341
- violations = aggregates.flat_map do |aggregate|
342
- aggregate.entities.flat_map do |entity|
343
- entity.commands.filter_map do |command|
344
- next unless command.references
345
- next if heads.include?(command.references.to_s)
346
-
347
- "#{aggregate.hecks_name}.#{entity.hecks_name}.#{command.hecks_name} names itself as its root"
348
- end
349
- end
350
- end
351
-
352
- return if violations.empty?
353
-
354
- raise Malformed,
355
- "an entity command is addressed through its aggregate; #{violations.uniq.join('; ')}"
356
- end
357
-
358
- # EVENTS ARE FIRST-CLASS BY CONVENTION, NOT BY DECLARATION (ADR
359
- # 0025, "events and reactions" — "a domain event is a value
360
- # object with its own attributes, not a label"). No new `event
361
- # do ... end` construct exists to hand-author and keep in step
362
- # with every emitting command by hand — an event's own known
363
- # shape IS whichever command(s) declare `emits` for its name, and
364
- # this is the ONE thing that has to hold for that convention to
365
- # mean anything: every command that emits a given name has to
366
- # agree on what it carries. An event is one fact; a fact does not
367
- # carry two different truths depending on who is telling it.
368
- #
369
- # STRUCTURAL fields only (name/type/list/optional) — `pattern:`/
370
- # `admits:`/`default:` are refinements ON a field, not a second
371
- # claim about what the payload holds, so two emitting commands
372
- # are free to differ there without actually disagreeing about
373
- # the event's own shape.
374
- def self.validate_event_shapes!(aggregates)
375
- event_emitters(aggregates).each do |event_name, pairs|
376
- next if pairs.size == 1
377
-
378
- shapes = pairs.map { |(owner, command)| event_shape(command, owner_aggregate(owner, aggregates)) }.uniq
379
- next if shapes.size == 1
380
-
381
- named = pairs.map { |(owner, command)| "#{owner}.#{command.hecks_name}" }.sort
382
- raise Malformed,
383
- "#{event_name.inspect} is emitted with different shapes by #{named.join(' and ')} — " \
384
- "an event is one fact, and every command that emits it must declare the same fields"
385
- end
386
- end
387
-
388
- # THE "EXPENSIVE HALF" the ADR names: "with: { account: :account }
389
- # projecting into a reaction that has no declared contract ...
390
- # breaks at dispatch rather than at load." Checked here, now that
391
- # `validate_event_shapes!` (above) guarantees at most one real
392
- # shape per event name, and command references being first-class
393
- # (`Naming.command_ref`) means the TARGET side is a real
394
- # resolvable command, not a string that might be a typo.
395
- #
396
- # SAME-CHAPTER ONLY, ON PURPOSE — a `with:` whose source event or
397
- # target command lives outside this chapter (an `across` policy
398
- # reacting to another domain's event entirely) is silently left
399
- # unchecked rather than refused: there is nothing here yet to
400
- # check it against, and "unresolvable" is not the same claim as
401
- # "wrong."
402
- #
403
- # A FOR_EACH POLICY'S SOURCE ISN'T THE EVENT AT ALL — a fan-out
404
- # `with:`'s symbols read the QUERY ROW `for_each` answers
405
- # (FreezeAccountsOnSuspension's own comment: "`account` is the
406
- # key the fan-out merges for each row"), which this has no shape
407
- # for; the SOURCE half is skipped for those, the TARGET half
408
- # (does the dispatched command actually declare the field) still
409
- # runs, since that half is true regardless of where the value
410
- # came from.
411
- def self.validate_with_projections!(policies, process_managers, aggregates)
412
- lookup = command_lookup(aggregates)
413
- heads = correlation_heads(process_managers)
414
-
415
- policies.each do |policy|
416
- next if policy.with_spec.to_a.empty?
417
-
418
- source_event = policy.for_each.to_s.empty? ? policy.on_event : nil
419
- check_with_spec!(policy.trigger_command, source_event, policy.with_spec, lookup,
420
- "#{policy.name}'s trigger", aggregates, heads)
421
- end
422
-
423
- process_managers.each do |pm|
424
- pm.handlers.each do |handler|
425
- handler.dispatches.each do |dispatch|
426
- next if dispatch.with_spec.to_a.empty?
427
-
428
- check_with_spec!(dispatch.command_name, handler.event_type, dispatch.with_spec, lookup,
429
- "#{pm.name}'s dispatch #{dispatch.command_name}", aggregates, heads, pm: pm)
430
- end
431
- end
432
- end
433
- end
434
-
435
- # `pm:` is present only for a process manager's own dispatch — a
436
- # saga leg's source symbol resolves against the CURRENT triggering
437
- # event first, same as a policy, but falls all the way back to the
438
- # saga's own MEMORY when the current event does not carry it
439
- # (`SagaInterpreter#dispatch_args`, its own last `else`) — and
440
- # memory starts as the OPENING event's payload
441
- # (`SagaInterpreter#instance = { ..., memory: event.payload }`,
442
- # never updated after), never the leg's own. Settlement's own
443
- # comment names exactly this: "the credit leg reads a destination
444
- # no event carried" — `AccountDebited` never declares `:reference`,
445
- # only `TransferRequested` (`pm.starts_on`) does, and that is
446
- # where the value is genuinely still coming from.
447
- def self.check_with_spec!(command_ref, event_name, with_spec, lookup, label, aggregates, correlation_heads, pm: nil)
448
- target = lookup[command_ref]
449
- source_shape = event_name && event_shape_for(event_name, aggregates)
450
- memory_shape = pm && event_shape_for(pm.starts_on, aggregates)
451
- correlation = pm && pm.correlates_by && pm.correlation_head
452
- # A POLICY'S SOURCE ALSO CARRIES THE EMITTER'S OWN IDENTITY —
453
- # `PolicyInterpreter#emitter_identity`, the runtime half of this.
454
- # An entity command's event never declares its aggregate's
455
- # identity (it arrives through `reference_to`, not an
456
- # `attribute`), so before this a policy on `KnightCaptured` could
457
- # not spell `with: { label: :label }` at all — "reads :label off
458
- # KnightCaptured, which does not declare it" — and chess's
459
- # AdvancePly grew optional, unread attributes just to survive a
460
- # wholesale forward. Policies only: a saga leg's own source is
461
- # `SagaInterpreter#dispatch_args`, which merges no such thing.
462
- identity_sources = pm.nil? && event_name ? event_identity_heads_for(event_name, aggregates) : []
463
-
464
- with_spec.each do |field, source|
465
- raise Malformed, "#{label}'s with: names #{field.inspect}, which #{command_ref} does not declare" if target && !command_declares?(target, field, aggregates, correlation_heads)
466
-
467
- next unless source.is_a?(::Symbol)
468
- next if source == correlation
469
- next if identity_sources.include?(source)
470
- next unless source_shape || memory_shape
471
-
472
- found = [source_shape, memory_shape].compact.any? { |shape| shape.any? { |name, *| name == source } }
473
- next if found
474
-
475
- raise Malformed, "#{label}'s with: reads :#{source} off #{event_name.inspect}, which does not declare it"
476
- end
477
- end
478
-
479
- # A command's OWN `reference_to` (bare, no `as:`) never lands in
480
- # `attributes` — `CommandBuilder#reference_to`'s self-reference
481
- # branch sets `command.references` instead (S2), and mints no new
482
- # field at all. What addresses it is not one name but the SAME
483
- # SET `CommandInterpreter::ArgumentGate#refuse_unknown_arguments`
484
- # already accepts at dispatch time — `:id`, the owning aggregate's
485
- # own `identity_heads` (real corpus proof — `Account.Debit`
486
- # dispatched everywhere as `number: ...`, `Account`'s own
487
- # `identified_by`), AND `Naming.reference_key(command.references)`
488
- # (real corpus proof — `FreezeAccountsOnSuspension`'s `for_each`
489
- # fan-out, whose own comment reads "`account` is the key the
490
- # fan-out merges for each row it answers"). Both are simultaneously
491
- # legal there, not context-dependent alternatives, so both are
492
- # legal here : this mirrors that gate rather than re-deriving a
493
- # narrower rule that would refuse one of two real, already-shipped
494
- # dispatch conventions.
495
- def self.command_declares?(command, field, aggregates, correlation_heads)
496
- return true if command.attributes.any? { |a| a.name == field }
497
- return true if field == :id
498
- return true if correlation_heads.include?(field)
499
- return false unless command.references
500
-
501
- referenced = aggregates.find { |a| a.hecks_name == command.references }
502
- return false unless referenced
503
-
504
- referenced.identity_heads.include?(field) || Naming.reference_key(command.references) == field
505
- end
506
-
507
- # THE FOURTH addressing key `ArgumentGate#refuse_unknown_arguments`
508
- # accepts, alongside `:id`/`identity_heads`/`reference_key` — every
509
- # saga in THIS domain's own `correlates_by` head, carried through
510
- # every dispatch as pure passthrough (Settlement's own comment:
511
- # "`reference:` carries the correlation forward... this is pure
512
- # passthrough, not an addressing key"). A command declaring none of
513
- # its attributes named this is not a gap; the correlation key rides
514
- # through commands that never read it, same as it does at runtime.
515
- def self.correlation_heads(process_managers)
516
- process_managers.filter_map { |pm| pm.correlates_by && pm.correlation_head }
517
- end
518
-
519
- # Every command this chapter declares, an aggregate's own AND
520
- # every entity nested inside one, paired with a name for what
521
- # declares it — shared by `validate_event_shapes!` and
522
- # `validate_with_projections!`'s own command lookup, the same
523
- # reach `HecksagonBuilder#commands_in` needs one level up (S8).
524
- def self.each_command(aggregates)
525
- return enum_for(:each_command, aggregates) unless block_given?
526
-
527
- aggregates.each do |aggregate|
528
- aggregate.commands.each { |command| yield aggregate.hecks_name, command }
529
- aggregate.entities.each do |entity|
530
- entity.commands.each { |command| yield "#{aggregate.hecks_name}.#{entity.hecks_name}", command }
531
- end
532
- end
533
- end
534
-
535
- # NOT MEMOISED — this used to be `@event_emitters ||=` on the
536
- # builder instance, which is safe for a one-file chapter but
537
- # wrong for one split across several: the FIRST file's build()
538
- # call would compute and cache it from whatever `@aggregates`
539
- # held at that moment, and every later file's own validation
540
- # would keep reading that same stale snapshot, silently missing
541
- # any command a later file adds. Recomputed fresh every call
542
- # instead — this walks the whole chapter once per `#build`, not
543
- # a hot path worth memoising at that cost.
544
- def self.event_emitters(aggregates)
545
- each_command(aggregates).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(owner, command), index|
546
- command.emits.each { |event_name| index[event_name] << [owner, command] }
547
- end
548
- end
549
-
550
- # STRUCTURAL, NOT NOMINAL. Two commands on two different
551
- # aggregates that both `emits "SameEvent"` are free to type a
552
- # field through two DIFFERENT, locally-scoped wrapper value
553
- # objects (e.g. one aggregate's own `value: SomeText` vs
554
- # another's `value: OtherText`, exactly the per-aggregate "own
555
- # text VO" convention every aggregate in this grammar already
556
- # follows for everything from `RuleText` to `FieldRef`) without
557
- # actually disagreeing about the event's shape — comparing
558
- # `a.type` by NAME would flag that as a violation for no real
559
- # reason: an event is one fact, and two isomorphic wrapper types
560
- # tell an identical one. So a value-object type is unwrapped to
561
- # its OWN attribute shape (recursively — a wrapper could itself
562
- # wrap another) before comparing, and only a primitive type
563
- # (nothing left to unwrap) or two VOs that truly differ once
564
- # unwrapped still counts as a real mismatch. `owner` carries the
565
- # type's `value_object` lookup — a command's own attributes only
566
- # know their type's NAME, never the aggregate that declared it,
567
- # and two sibling aggregates in one chapter each keep a
568
- # same-named VO private to themselves, so the unwrap has to ask
569
- # the SAME aggregate the field's own command belongs to, never a
570
- # neighbor's.
571
- def self.event_shape(command, owner)
572
- command.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s), a.list?, a.optional?] }.sort
573
- end
574
-
575
- def self.unwrap_shape(owner, type_name, seen = [])
576
- return type_name if owner.nil? # owner couldn't be resolved -- compare by name, same as before this unwrap existed
577
- return type_name if Attribute::PRIMITIVES.include?(type_name)
578
- return type_name if seen.include?(type_name) # a self-referential VO bottoms out on its own name, not an infinite unwrap
579
-
580
- shape = owner.value_object(type_name)
581
- return type_name unless shape # not this owner's own VO (a reference type, say) -- nothing further to unwrap
582
-
583
- shape.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s, seen + [type_name]), a.list?, a.optional?] }.sort
584
- end
585
-
586
- # `owner` (from `each_command`) is a plain STRING — the aggregate's
587
- # `hecks_name` alone, or `"Aggregate.Entity"` for an entity's own
588
- # command. Either way the VALUE OBJECTS a command's fields can be
589
- # typed with are the AGGREGATE's own (`Entity` carries no
590
- # `value_object` lookup of its own — the whole rest of this file
591
- # already resolves hop/type lookups only at the aggregate level,
592
- # e.g. `validate_hop_tail!`'s `target.value_object(type)`), so only
593
- # the first segment ever matters here.
594
- def self.owner_aggregate(owner, aggregates)
595
- aggregates.find { |a| a.hecks_name == owner.to_s.split(".").first }
596
- end
597
-
598
- def self.event_shape_for(event_name, aggregates)
599
- pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
600
- return nil if pairs.empty?
601
-
602
- owner_name, command = pairs.first
603
- event_shape(command, owner_aggregate(owner_name, aggregates))
604
- end
605
-
606
- # The identity heads of the aggregate that emits `event_name` — an
607
- # entity's event is stamped with its OWNING aggregate's identity
608
- # (`Event#id` is the parent's), so an owner spelled "Game.Knight"
609
- # answers Game's heads.
610
- def self.event_identity_heads_for(event_name, aggregates)
611
- pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
612
- return [] if pairs.empty?
613
-
614
- owner_name, = pairs.first
615
- aggregate = owner_aggregate(owner_name, aggregates)
616
- return [] unless aggregate
617
-
618
- heads = aggregate.identity_heads.map(&:to_sym)
619
- # AN ENTITY'S EVENT ALSO CARRIES THE PIECE'S OWN IDENTITY — the
620
- # args a piece was addressed by are the args its event announces
621
- # (`Emission#emit`: `payload: args`), so `id`-shaped heads are
622
- # genuinely there at runtime even though no `attribute` line on
623
- # the entity command declares them.
624
- entity_names = owner_name.to_s.split(".").drop(1)
625
- entity = entity_names.reduce(aggregate) { |owner, name| owner&.entities&.find { |e| e.hecks_name == name } }
626
- heads + (entity ? entity.identity_heads.map(&:to_sym) : [])
627
- end
628
-
629
- def self.command_lookup(aggregates)
630
- each_command(aggregates).each_with_object({}) do |(owner, command), index|
631
- index["#{owner}.#{command.hecks_name}"] = command
632
- end
633
- end
634
-
635
- # A REFERENCE RING IS NOT A MODELLING CHOICE, IT IS A MISSING ONE
636
- # — a DDD aggregate is a consistency boundary precisely because
637
- # something outside it can only ever point IN, by id, never the
638
- # other way. A caller must be able to reason about one aggregate
639
- # alone ; a ring back to where it started means no aggregate in
640
- # it is a boundary anyone can reason about without the rest of
641
- # the ring, and the whole ring is really one aggregate wearing
642
- # several names.
643
- #
644
- # Checked at the bluebook level, not inside `AggregateBuilder`
645
- # itself, because seeing a cycle needs every end declared — an
646
- # aggregate finishes building long before it can know whether
647
- # some later aggregate in the same file points back at it.
648
- #
649
- # ACYCLIC WITHIN A CHAPTER (ADR 0025, "References") — widened
650
- # from the direct pair (A -> B -> A) this used to catch alone to
651
- # any ring, however long (A -> B -> C -> A), the same DFS
652
- # coloring a reference graph needs for any cycle. A cross-chapter
653
- # reference is UNREACHABLE here rather than unchecked:
654
- # `Reference#resolve` is scoped to its own chapter by
655
- # construction, so a target this chapter never declares is a
656
- # dangling name, not an edge — `edges.key?` below is what keeps
657
- # the walk from ever leaving this chapter's own aggregates.
658
- # Self-reference stays legal (`parent.parent.name` for a
659
- # hierarchy is real and safe) — excluded the same way the
660
- # direct-pair check already excluded it.
661
- def self.validate_no_bidirectional_references!(aggregates)
662
- edges = aggregates.each_with_object({}) do |aggregate, index|
663
- index[aggregate.hecks_name] = aggregate.reference_targets.uniq.reject { |target| target == aggregate.hecks_name }
664
- end
665
-
666
- cycle = find_reference_cycle(edges)
667
- return unless cycle
668
-
669
- ring = "#{cycle.join(' -> ')} -> #{cycle.first}"
670
- raise Malformed,
671
- "reference cycle: #{ring} — an aggregate points at another by id, and a " \
672
- "ring back to where it started means no aggregate in it is a boundary " \
673
- "anyone can reason about alone ; break the ring, or let one side be found " \
674
- "through a query instead of a reference pointing back"
675
- end
676
-
677
- # Plain DFS with a visiting/done coloring, over the reference
678
- # graph THIS chapter's own aggregates declare. Returns the ring
679
- # itself (in the order it closes), or nil.
680
- def self.find_reference_cycle(edges)
681
- state = {}
682
-
683
- edges.each_key do |start|
684
- cycle = reference_cycle_from(start, edges, state, [])
685
- return cycle if cycle
686
- end
687
-
688
- nil
689
- end
690
-
691
- def self.reference_cycle_from(node, edges, state, path)
692
- return nil if state[node] == :done
693
- return path[path.index(node)..] if state[node] == :visiting
694
-
695
- state[node] = :visiting
696
- path.push(node)
697
-
698
- edges[node].each do |target|
699
- next unless edges.key?(target) # a name this chapter never declares is dangling, not an edge
700
-
701
- found = reference_cycle_from(target, edges, state, path)
702
- return found if found
703
- end
704
-
705
- path.pop
706
- state[node] = :done
707
- nil
708
- end
709
-
710
- # THE OTHER HALF OF A HOP — AggregateBuilder#seal_query_field
711
- # recognised the HEAD of a dotted where-field that names one of
712
- # its own references and deferred it here, unable to check
713
- # further: it cannot yet resolve what the reference points AT.
714
- # This runs once every aggregate exists in one chapter, so it
715
- # can.
716
- #
717
- # Only WHERE clauses ever reach here — a hop on ORDER BY is
718
- # refused outright, immediately, back in seal_query_field
719
- # itself (that answer never needed the target's shape).
720
- #
721
- # AN ENTITY'S OWN QUERIES DID reach `EntityBuilder#reference_to`
722
- # (added after this comment first claimed otherwise — S9, ADR
723
- # 0025) without ever reaching HERE: tier-1 sealing
724
- # (`AggregateBuilder#query_surfaces`) already recognises a hop
725
- # on an entity's own field and DEFERS it exactly like an
726
- # aggregate's, but nothing ever walked entity queries at tier 2
727
- # to check the deferral — a bad hop, or even a well-formed one,
728
- # built silently and then matched nothing at runtime
729
- # (`QueryInterpreter#entity_rows` reads an element's fields by
730
- # literal hash key, never follows a reference). Refused outright
731
- # here instead of taught to follow the hop for real: no corpus
732
- # member needs an entity query to cross a reference yet, and a
733
- # named refusal beats a runtime that resolves nothing while
734
- # looking like it might.
735
- def self.validate_query_hops!(bluebook)
736
- bluebook.aggregates.each do |aggregate|
737
- aggregate.queries.each do |query|
738
- query.wheres.each do |clause|
739
- next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
740
-
741
- validate_hop_clause!(aggregate, query, clause)
742
- end
743
- end
744
-
745
- aggregate.entities.each { |entity| refuse_entity_query_hops!(aggregate, entity) }
746
- end
747
- end
748
-
749
- # The chapter-wide half of AggregateBuilder's local query-argument
750
- # inference. A hop cannot resolve while its aggregate is still being
751
- # built; here every Reference has an owner and target, so a symbolic
752
- # comparison can inherit the type of the scalar it compares without a
753
- # duplicate query-local declaration.
754
- def self.infer_hop_query_arguments!(bluebook)
755
- bluebook.aggregates.each do |aggregate|
756
- aggregate.queries.each do |query|
757
- query.wheres.each do |clause|
758
- name = clause.value
759
- next unless name.is_a?(Symbol)
760
- next if query.attribute(name)
761
- next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
762
-
763
- plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes)
764
- next if plan.refusal || plan.hops.empty?
765
-
766
- target = plan.hops.last.target
767
- head, *nested = plan.tail.to_s.split(".")
768
- leaf = if nested.empty? && target.lifecycle&.field.to_s == head
769
- Attribute.new(name: name, type: String)
770
- else
771
- root = target.attributes.find { |candidate| candidate.name.to_s == head }
772
- found = root && QuerySpecification::FieldPath.leaf_attribute(root, nested) do |type|
773
- target.value_object(type)
774
- end
775
- found && Attribute.new(name: name, type: found.type, list: found.list?)
776
- end
777
- next unless leaf
778
-
779
- query.attributes << leaf
780
- end
781
- end
782
- end
783
- end
784
-
785
- def self.refuse_entity_query_hops!(aggregate, entity)
786
- entity.queries.each do |query|
787
- query.wheres.each do |clause|
788
- next unless QuerySpecification::HopPath.hop_head?(clause.field, entity.attributes)
789
-
790
- raise Malformed,
791
- "#{aggregate.hecks_name}::#{entity.hecks_name}.#{query.hecks_name} asks about " \
792
- "#{clause.field}, which hops through #{entity.hecks_name}'s own reference — " \
793
- "an entity query does not follow a hop the way an aggregate's own does; ask " \
794
- "through the aggregate's own query instead, or open the target directly"
795
- end
796
- end
797
- end
798
-
799
- def self.validate_hop_clause!(aggregate, query, clause)
800
- plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes)
801
-
802
- case plan.refusal
803
- when :unresolvable
804
- # HopPath.plan pushes even an unresolved hop onto `hops`
805
- # before reporting this, specifically so `target_name` —
806
- # real, known at declaration, independent of whether
807
- # `resolve` succeeded — is always here to name.
808
- raise Malformed,
809
- "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
810
- "which hops to #{plan.hops.last.target_name}, which this chapter never " \
811
- "declares — a hop into an aggregate this chapter cannot see resolves to " \
812
- "nothing, and a where that resolves to nothing matches nothing and " \
813
- "refuses nothing"
814
- when :too_deep
815
- raise Malformed,
816
- "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
817
- "whose hop chain reaches #{QuerySpecification::HopPath::MAX_HOPS} " \
818
- "references deep without landing — a chain this long is refused as a " \
819
- "likely mistake, not a structural limit"
820
- end
821
-
822
- target = plan.hops.last.target
823
- validate_hop_tail!(aggregate, query, clause, target, plan.tail)
824
- end
825
-
826
- # The same three-way answer seal_query_field gives for its OWN
827
- # aggregate's fields — landing on a real scalar (fine), landing
828
- # on a value object (refused by name), or naming nothing at all
829
- # (refused by name) — asked instead of the hop's TARGET aggregate,
830
- # since that is whose shape the tail actually has to answer for.
831
- def self.validate_hop_tail!(aggregate, query, clause, target, tail)
832
- name, *nested = tail.to_s.split(".")
833
- attribute = target.attributes.find { |candidate| candidate.name.to_s == name }
834
- return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if
835
- nested.empty? && (attribute || target.lifecycle&.field.to_s == name)
836
- return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if
837
- nested.any? && attribute &&
838
- QuerySpecification::FieldPath.scalar_leaf?(attribute, nested) { |type| target.value_object(type) }
839
-
840
- if nested.any? && attribute &&
841
- !QuerySpecification::FieldPath.leaf_attribute(attribute, nested) { |type| target.value_object(type) }.nil?
842
- raise Malformed,
843
- "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
844
- "which hops to #{target.hecks_name} and then asks about #{tail}, which " \
845
- "lands on a value object, not a scalar — a dotted query path ends on a " \
846
- "scalar member, or the engines answer it differently"
847
- end
848
-
849
- raise Malformed,
850
- "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
851
- "which hops to #{target.hecks_name} and then asks about #{tail}, which " \
852
- "#{target.hecks_name} never declares — a query over a field that does " \
853
- "not exist matches nothing and refuses nothing"
854
- end
855
-
856
- # A WHERE hop with an ordered comparator is legitimate ("client
857
- # whose balance > 500") — AggregateBuilder#seal_ordered_comparator
858
- # already deferred this exact check for the same reason every
859
- # other hop check is deferred, and this is where it gets asked,
860
- # against the hop's TARGET instead of the querying aggregate.
861
- def self.validate_hop_comparator!(aggregate, query, clause, target, attribute, nested)
862
- return unless AggregateBuilder::ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym)
863
- return if attribute &&
864
- QuerySpecification::FieldPath.numeric?(attribute, nested) { |type| target.value_object(type) }
865
-
866
- held = attribute ? "holds no number" : "is the lifecycle field, which holds text"
867
- raise Malformed,
868
- "#{aggregate.hecks_name}.#{query.hecks_name} compares #{clause.field} with " \
869
- "#{clause.op} after hopping to #{target.hecks_name}, but the field it lands " \
870
- "on #{held} — an ordered comparison needs a numeric field, and over " \
871
- "anything else the adapters answer differently or not at all"
872
- end
873
-
874
- # THE TARGET HALF of `projects` validation (S12, ADR 0025) —
875
- # `AggregateBuilder#seal_projected_fields` already checked the
876
- # LOCAL half at declare time (the reference names a real
877
- # `reference_to` on THIS aggregate); this checks the reference
878
- # actually resolves to a real aggregate in this chapter, and
879
- # that aggregate really declares `remote_field` as a scalar.
880
- #
881
- # Reuses `QuerySpecification::HopPath` rather than re-deriving
882
- # hop resolution a second way — `"reference/remote_field"` is
883
- # the same single-hop shape a query's own `/`-spelled hop
884
- # resolves, even though `projects`'s own DSL spelling is dotted
885
- # (`from: :"customer.status"`): two constructs, two spellings,
886
- # one resolution primitive. A single hop can never reach
887
- # HopPath::MAX_HOPS, so :too_deep is structurally unreachable
888
- # here and is not special-cased.
889
- def self.validate_projected_fields!(bluebook)
890
- bluebook.aggregates.each do |aggregate|
891
- aggregate.projected_fields.each { |field| validate_projected_field!(aggregate, field) }
892
- end
893
- end
894
-
895
- def self.validate_projected_field!(aggregate, field)
896
- plan = QuerySpecification::HopPath.plan("#{field.reference}/#{field.remote_field}", aggregate.attributes)
897
-
898
- if plan.refusal == :unresolvable
899
- raise Malformed,
900
- "#{aggregate.hecks_name}.projects :#{field.name} reads through :#{field.reference}, " \
901
- "which hops to #{plan.hops.last.target_name}, which this chapter never declares — " \
902
- "a projection through an aggregate this chapter cannot see resolves to nothing"
903
- end
904
-
905
- target = plan.hops.last.target
906
- remote_attribute = target.attributes.find { |candidate| candidate.name.to_s == plan.tail }
907
-
908
- # THE WORKED EXAMPLE ITSELF (ADR 0025) reads through a
909
- # LIFECYCLE field — banking's Customer.status is `lifecycle
910
- # :status`, never a plain `attribute` — the same fallback
911
- # validate_hop_tail! already gives a query's own hop tail. A
912
- # lifecycle field is always a plain string by construction ;
913
- # nothing further to check once it matches by name.
914
- return if remote_attribute.nil? && target.lifecycle&.field.to_s == plan.tail
915
-
916
- # A PROJECTION MAY CHAIN THROUGH ANOTHER PROJECTION (S12, ADR
917
- # 0025's own boundary rule, followed through) — `target`'s
918
- # OWN projected fields live in `projected_fields`, a
919
- # separate list from `attributes`, so a match there is
920
- # invisible to the check above even though it names a real,
921
- # always-current, stored field. `Transfer.projects
922
- # :source_customer_status, from: :"source.customer_status"`
923
- # reads Account's own already-projected `customer_status`
924
- # this way — Account is one hop from Customer, Transfer is
925
- # one hop from Account, and neither aggregate needs to know
926
- # about the other's target two hops away. A projected
927
- # field's remote value is always a scalar by construction
928
- # (`RebuildSweep.remote_value` never copies a reference, a
929
- # value object, or a list), so nothing further to check once
930
- # it matches by name — same reasoning the lifecycle
931
- # fallback just above already applies.
932
- return if remote_attribute.nil? && target.projected_fields.any? { |f| f.name.to_s == plan.tail }
933
-
934
- unless remote_attribute
935
- raise Malformed,
936
- "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \
937
- "#{plan.tail.inspect}, which #{target.hecks_name} never declares"
938
- end
939
-
940
- return if projectable_scalar?(target, remote_attribute)
941
-
942
- raise Malformed,
943
- "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \
944
- "#{plan.tail.inspect}, which is not a scalar — a projected field copies a single " \
945
- "value, never a reference, a value object, or a list"
946
- end
947
-
948
- def self.projectable_scalar?(target, attribute)
949
- !attribute.list? && !attribute.reference? && target.value_object(attribute.type).nil?
950
- end
951
-
952
- # `correlates_by` NAMES A SCALAR, NOW CHECKED RATHER THAN TRUSTED.
953
- #
954
- # ProcessManagerBuilder#validate! already refuses a bare, undotted
955
- # spelling — a SYNTACTIC guarantee that the declaration cannot leave
956
- # the question open. It cannot go further: a process manager is built
957
- # in isolation, before this chapter's aggregates exist to check
958
- # against. Here, with the whole document assembled, the dotted path
959
- # is walked for real — against whichever command actually emits an
960
- # event this process manager reacts to — so a path that still lands
961
- # on a value object is refused before the runtime ever has to decide
962
- # what a non-scalar correlation key even means: a saga keys off this
963
- # value directly, and a value object carries no guaranteed-stable
964
- # identity to key on the way a scalar does.
965
- #
966
- # A command that does not declare the path's first segment at all is
967
- # silently skipped, not refused — correlation has two other fallback
968
- # tiers below the payload dig (a correlation stamp, then the emitting
969
- # aggregate's own reference key; saga_interpreter/correlation.rb), so
970
- # an absent field is not this check's business. Only a field that
971
- # resolves, and resolves to something other than a scalar, is.
972
- def self.validate_correlation_keys!(process_managers, aggregates)
973
- process_managers.each do |pm|
974
- next unless pm.correlates_by
975
-
976
- reason = correlation_key_violation(pm, aggregates)
977
- next unless reason
978
-
979
- raise ProcessManagerBuilder::InvalidProcessManager,
980
- "#{pm.name} correlates_by #{pm.correlates_by.inspect}, but #{reason}"
981
- end
982
- end
983
-
984
- def self.correlation_key_violation(pm, aggregates)
985
- head, *rest = pm.correlates_by.to_s.split(".")
986
- events = reacted_events(pm)
987
-
988
- emitting_commands(events, aggregates).each do |owner, command|
989
- attribute = command.attributes.find { |a| a.name == head.to_sym }
990
- next unless attribute
991
-
992
- reason = list_or_scalar_violation(owner, attribute, rest)
993
- return reason if reason
994
- end
995
-
996
- nil
997
- end
998
-
999
- def self.reacted_events(pm)
1000
- ([pm.starts_on, pm.ends_on] + pm.handlers.map(&:event_type))
1001
- .compact
1002
- .reject { |event| event == ProcessManager::REFUSED }
1003
- .map { |event| event.to_s.split("::").last }
1004
- .uniq
1005
- end
1006
-
1007
- def self.emitting_commands(events, aggregates)
1008
- aggregates.flat_map do |aggregate|
1009
- commands = aggregate.commands + aggregate.entities.flat_map(&:commands)
1010
- commands.select { |command| (command.emits.map(&:to_s) & events).any? }
1011
- .map { |command| [aggregate, command] }
1012
- end
1013
- end
1014
-
1015
- def self.list_or_scalar_violation(owner, attribute, segments)
1016
- return "#{attribute.name} is a list — a correlation key must name one instance's own field, " \
1017
- "not a whole collection" if attribute.list?
1018
-
1019
- walk_scalar(owner, attribute.type.to_s, segments)
1020
- end
1021
-
1022
- # Walks the remaining dotted segments through nested value objects.
1023
- # `type_name` starts as the head attribute's own declared type ; each
1024
- # step either bottoms out at a real scalar (nil — no violation) or
1025
- # names why it cannot: still a value object with no more path left,
1026
- # a value object this domain never declared, a field that value
1027
- # object does not have, or a segment left over after already
1028
- # reaching a scalar.
1029
- def self.walk_scalar(owner, type_name, segments)
1030
- if segments.empty?
1031
- return nil if Attribute::PRIMITIVES.include?(type_name)
1032
-
1033
- return "#{type_name} is a value object, not a scalar — name one of its own fields, " \
1034
- "e.g. #{type_name.downcase}.value"
1035
- end
1036
-
1037
- return "#{type_name} is already a scalar — #{segments.join('.')} has nothing left to reach" if Attribute::PRIMITIVES.include?(type_name)
1038
-
1039
- shape = owner.value_object(type_name)
1040
- return "#{type_name} is not a value object this domain declares" unless shape
1041
-
1042
- segment, *rest = segments
1043
- attribute = shape.attributes.find { |a| a.name == segment.to_sym }
1044
- return "#{type_name} has no field #{segment.inspect}" unless attribute
1045
- return "#{type_name}.#{segment} is a list — a correlation key must name one instance's own field, " \
1046
- "not a whole collection" if attribute.list?
1047
-
1048
- walk_scalar(owner, attribute.type.to_s, rest)
1049
- end
1050
-
1051
292
  # A CHAPTER MAY BE DECLARED IN SEVERAL FILES, meant to merge into ONE
1052
293
  # domain — `lib/hecks/language/bluebook/*.bluebook` all open
1053
294
  # `Hecks.bluebook "Bluebook" do ... end`. Each `Hecks.bluebook` call used to