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,524 @@
1
+ module Hecks
2
+ module Bluebook
3
+ module DSL
4
+ class AggregateBuilder
5
+ # THE "SEAL_*" PASS — everything `#build` runs once every
6
+ # declaration (attributes, entities, commands, queries, the
7
+ # lifecycle) is otherwise in place, checking that what a command,
8
+ # query, or default NAMES actually exists elsewhere on the
9
+ # aggregate. Split out of aggregate_builder.rb (which keeps the
10
+ # DSL surface itself — attribute/command/query/policy declaration
11
+ # — and the smaller drain_pending!/identity bookkeeping) because
12
+ # this cluster is one cohesive concern: cross-field validation
13
+ # that can only run after every declaration is real, the same
14
+ # relationship BluebookBuilder's own `self.validate_*` cluster has
15
+ # to ITS chapter (see that file's own header for the parallel).
16
+ # `include`d back into AggregateBuilder, same pattern as
17
+ # `Runtime::Registry`'s own `include Verification` /
18
+ # `include SagaPersistence` — same class, its private instance
19
+ # methods, just filed by responsibility across files.
20
+ module Sealing
21
+ private
22
+
23
+ # Every reference is told which Aggregate declares it, so it can
24
+ # find the chapter and resolve its target.
25
+ #
26
+ # Stamped HERE, at build, rather than at `reference_to`, because a command
27
+ # builder does not hold the aggregate and should not learn to. And
28
+ # deliberately across every list that can carry one — a reference the walk
29
+ # missed would resolve to nil, and `resolve_references` SKIPS a nil target,
30
+ # so the guarantee would go quiet instead of going red. That is the exact
31
+ # shape of the bug that let an Account belong to an unregistered customer
32
+ # fourteen times over.
33
+ def stamp_references(aggregate)
34
+ reference_bearing_attributes.each { |attribute| attribute.type.declared_in = aggregate }
35
+ end
36
+
37
+ # AN OWNED PIECE'S OWN `reference_to` IS AN EDGE THIS AGGREGATE
38
+ # POINTS ACROSS TOO (S9, ADR 0025 — "entity/aggregate shared
39
+ # vocabulary") — a ring closing through a contained piece (Board
40
+ # -> Board::Card -> Product -> Board) is the same "no boundary
41
+ # anyone can reason about alone" `validate_no_bidirectional_
42
+ # references!` already refuses for a direct aggregate-to-
43
+ # aggregate ring; it was invisible before this because only
44
+ # `AggregateBuilder#reference_to` ever fed `@reference_targets`,
45
+ # never `EntityBuilder#reference_to`. Command/query reference
46
+ # ARGUMENTS are deliberately excluded — they are data flowing
47
+ # through a dispatch, not persisted state the graph a cycle
48
+ # means anything over.
49
+ def entity_reference_targets
50
+ @entities.flat_map { |entity| entity.attributes.select(&:reference?).map { |a| a.type.target_name.to_s } }
51
+ end
52
+
53
+ def reference_bearing_attributes
54
+ lists = [attributes, *@commands.map(&:attributes), *@queries.map(&:attributes)]
55
+ @entities.each do |entity|
56
+ lists << entity.attributes
57
+ lists.concat(entity.commands.map(&:attributes))
58
+ lists.concat(entity.queries.map(&:attributes))
59
+ end
60
+
61
+ lists.flatten.select(&:reference?)
62
+ end
63
+
64
+ # A mutation must name a field the aggregate actually HAS.
65
+ #
66
+ # NOT moved to the language, and deliberately so. The language says only
67
+ # `given("a mutation names a target") { !target.value.to_s.empty? }` —
68
+ # non-emptiness — because saying more means reaching a list that lives on
69
+ # a DIFFERENT root : a command's changes hang off Command, the fields they
70
+ # name hang off Aggregate, and a given is a closed predicate over its own
71
+ # state. Aggregate.Seal is the right shape and cannot see commands ; the
72
+ # reference trick that rescued "attributes use value-object types" needs a
73
+ # root to point at, and an aggregate's fields are a value-object list, not
74
+ # roots. This is the second rule that cannot port for that reason — the
75
+ # first is read-model uniqueness — and both wait on the same thing : a
76
+ # quantifier, or fields promoted to roots.
77
+ #
78
+ # So it lives here, at build, where every declaration is present. Found by
79
+ # writing `then_set :disputed_by` on CardPayment before the field existed :
80
+ # it wrote into nothing, refused nothing, and every check stayed green.
81
+ # A DEFAULT FILLS THE SHAPE IT IS DECLARED ON, or it fills nothing.
82
+ #
83
+ # `attribute :cover, one_of("covered", "open"), default: "open"` builds
84
+ # cleanly and then refuses EVERY create at dispatch — "cover is a Cover,
85
+ # pass its fields as an object" — because the value object wants its
86
+ # fields and got a bare string. The bluebook is wrong at the line where
87
+ # it is written and says so nowhere near it.
88
+ #
89
+ # It cost a corpus member 33 refusals out of 40 steps, with every gate
90
+ # green throughout: the refusals were perfectly consistent, which is
91
+ # consistency about nothing. `till.bluebook` has always had the right shape
92
+ # — `default: { cents: 0 }`.
93
+ #
94
+ # A PRIMITIVE takes a scalar and a VALUE OBJECT takes its fields, so the
95
+ # test is simply which one the type names. Nothing here guesses at the
96
+ # keys: a default that is a Hash is left to `Value.for_attribute`, which
97
+ # is where a wrong FIELD belongs.
98
+ def seal_defaults
99
+ # `closed_sets` TOO, not only `@value_objects` — the exact gap
100
+ # this method's own comment names: an inline `one_of(...)`
101
+ # synthesises its value object through `closed_sets`
102
+ # (AttributeCollector#synthesise_closed_set), never installed
103
+ # into `@value_objects` until `#build` merges them (see
104
+ # `#build`'s own `@value_objects + closed_sets`, and
105
+ # `declared_value_object`'s identical merge). Checking
106
+ # `@value_objects` alone made this exact attribute — a bare
107
+ # default on an inline closed set — invisible to the one
108
+ # check meant to catch it.
109
+ shapes = (@value_objects + closed_sets).map { |shape| shape.hecks_name.to_s }
110
+
111
+ attributes.each do |attribute|
112
+ next if attribute.default.nil? || attribute.default.is_a?(Hash)
113
+ next unless shapes.include?(attribute.type.to_s)
114
+
115
+ raise Malformed,
116
+ "#{@name}.#{attribute.name} defaults to #{attribute.default.inspect}, but " \
117
+ "#{attribute.type} is a value object — a default fills its FIELDS " \
118
+ "(default: { ... }), and a bare value refuses every create instead"
119
+ end
120
+ end
121
+
122
+ # A command's `from:` guard needs a lifecycle field to check
123
+ # against — declared at BUILD time (S10, ADR 0025), the same
124
+ # point every other "does this actually resolve" check in this
125
+ # file runs, rather than left to crash `enforce_lifecycle_
126
+ # guard` the first time such a command is ever dispatched.
127
+ def seal_lifecycle_guards
128
+ return if @lifecycle
129
+
130
+ @commands.each do |command|
131
+ next unless command.from
132
+
133
+ raise Malformed,
134
+ "#{@name}.#{command.hecks_name} guards from: #{Array(command.from).inspect}, but " \
135
+ "#{@name} declares no lifecycle — from: checks a lifecycle field, and there is " \
136
+ "none here to check"
137
+ end
138
+ end
139
+
140
+ # `projects`'s OWN half of "does this actually resolve" (S12,
141
+ # ADR 0025) — the LOCAL half only: `reference` must name a real
142
+ # reference-typed attribute this aggregate declares, and
143
+ # `name` must not collide with an attribute already declared
144
+ # (a projected field is its own kind of field, never a second
145
+ # spelling of one that already exists). The TARGET aggregate's
146
+ # own field is checked separately, once every aggregate in the
147
+ # chapter is real — see BluebookBuilder#validate_projected_
148
+ # fields!'s own comment for why that half cannot happen here.
149
+ def seal_projected_fields
150
+ declared = attributes.map { |attribute| attribute.name.to_sym }
151
+
152
+ @projected_fields.each do |field|
153
+ if declared.include?(field.name)
154
+ raise Malformed,
155
+ "#{@name}.projects :#{field.name} names a field #{@name} already declares — " \
156
+ "a projected field is never a second spelling of one that already exists"
157
+ end
158
+
159
+ reference_attribute = attributes.find { |attribute| attribute.name == field.reference }
160
+ unless reference_attribute&.reference?
161
+ raise Malformed,
162
+ "#{@name}.projects :#{field.name} reads through #{field.reference.inspect}, which " \
163
+ "#{@name} never declares as a reference_to — projects reads through a REFERENCE, " \
164
+ "never a value object or a scalar"
165
+ end
166
+ end
167
+ end
168
+
169
+ def seal_mutation_targets
170
+ known = attributes.map { |attribute| attribute.name.to_sym }
171
+ known << @lifecycle.field.to_sym if @lifecycle
172
+
173
+ @commands.each do |command|
174
+ command.mutations.each do |mutation|
175
+ # `:delegate` — CommandBuilder#delegates_to's own comment —
176
+ # targets no field of THIS aggregate at all; its `target`
177
+ # names an "Entity.Command" pair instead, checked when the
178
+ # command builds (`delegates_to`'s own `rpartition` guard)
179
+ # and again at dispatch time (`CommandInterpreter
180
+ # #step_delegate_to_entity`, which refuses a real one that
181
+ # names no such entity or command). Sealing THIS check
182
+ # against it would refuse every delegating command outright.
183
+ # `:corrects` — CommandBuilder#corrects_impl's own comment —
184
+ # targets an EVENT name, not a field either; checked instead
185
+ # by `seal_correction_targets`, below.
186
+ next if [:delegate, :corrects].include?(mutation.op)
187
+
188
+ # C5.3 (docs/semantics/bluebook-semantics.md) — the
189
+ # lifecycle field moves ONLY by transition; a `sets` on it
190
+ # would be overwritten by any transition and bypass the
191
+ # state machine otherwise. Refused at build — except for
192
+ # FROZEN ERA TEXT (`MetaValidator.shadow_parsing?`), which
193
+ # is history and must keep parsing as the language tightens.
194
+ if @lifecycle && mutation.target.to_sym == @lifecycle.field.to_sym && !MetaValidator.shadow_parsing?
195
+ raise Malformed,
196
+ "#{@name}.#{command.hecks_name} sets #{mutation.target}, #{@name}'s lifecycle field — " \
197
+ "a lifecycle field moves only by transition; declare one instead of setting it"
198
+ end
199
+ next if known.include?(mutation.target.to_sym)
200
+
201
+ raise Malformed,
202
+ "#{@name}.#{command.hecks_name} sets #{mutation.target}, which #{@name} " \
203
+ "never declares — a mutation into a field that does not exist " \
204
+ "writes nothing and refuses nothing"
205
+ end
206
+ end
207
+ end
208
+
209
+ # `corrects` — CommandBuilder#corrects_impl's own comment. Runs
210
+ # once every command in the aggregate is known (the same reason
211
+ # this is a `seal_*` step rather than living in `corrects_impl`
212
+ # itself — a command cannot see its own siblings' `emits` while
213
+ # it is still being built). Two things are checked:
214
+ #
215
+ # 1. The named event must be something a SIBLING command here
216
+ # actually `emits` — naming an event nothing in this aggregate
217
+ # ever announces is a build-time authoring error. (Whether
218
+ # THIS record has actually emitted it YET is the dispatch-time
219
+ # half — CommandRules::Admissibility#enforce_correction_target.)
220
+ #
221
+ # 2. `reverses: true` derives the corrective `sets` from the
222
+ # ORIGINAL command's own mutations, rather than the author
223
+ # writing them — but only when every one of those mutations is
224
+ # STRUCTURALLY invertible with no runtime data: increment/
225
+ # decrement, same argument, opposite verb (`sign_for`'s own
226
+ # +1/-1 pair — CommandRules::Arithmetic applies `current +
227
+ # sign * amount`, so the SAME source with the OPPOSITE sign
228
+ # undoes it exactly). Nothing else qualifies today: `set` has
229
+ # no such rule at all — inverting it needs the SPECIFIC prior
230
+ # value at the moment the original fired, which is per-
231
+ # instance runtime data no build-time derivation can have;
232
+ # `multiply`/`clamp` are lossy by design (a clamped value's
233
+ # own pre-clamp magnitude is not recoverable from the mutation
234
+ # at all); `append`/`remove` LOOK symmetric but are not
235
+ # reliably so — `append`'s source is a per-field binding hash
236
+ # (`append: { name: :name, amount: :amount }`), `remove`'s is
237
+ # a single resolved value to match by equality
238
+ # (MutationApplier#removed), and collapsing one shape into the
239
+ # other correctly needs the target list's own value-object
240
+ # field names, not just the mutation's own recorded shape — a
241
+ # real gap, left for a follow-on round rather than guessed at
242
+ # here. Refuses rather than silently deriving something wrong
243
+ # — see docs/decisions/ for the ADR that draws this exact
244
+ # line.
245
+ # One closed cluster of `corrects`/`reverses: true` rules, run
246
+ # in sequence against ONE command at a time (emission exists,
247
+ # reverses/own-sets conflict, invertibility, then the actual
248
+ # derivation) — each `raise` gates the next check for THAT
249
+ # command, and `inverse_op`/`emitted_by` are shared read-only
250
+ # lookups built once up front. Splitting the per-command body
251
+ # out would still need all of `command`/`event`/`sources`/
252
+ # `inverse_op` passed in, for no clearer a result.
253
+ # rubocop:disable-next Metrics/AbcSize
254
+ # rubocop:disable-next Metrics/CyclomaticComplexity
255
+ # rubocop:disable-next Metrics/PerceivedComplexity
256
+ def seal_correction_targets
257
+ inverse_op = { increment: :decrement, decrement: :increment }
258
+ emitted_by = Hash.new { |hash, key| hash[key] = [] }
259
+ @commands.each { |command| command.emits.each { |event_name| emitted_by[event_name] << command } }
260
+
261
+ @commands.each do |command|
262
+ correction = command.mutations.find { |mutation| mutation.op == :corrects }
263
+ next unless correction
264
+
265
+ event = correction.target
266
+ sources = emitted_by[event]
267
+ if sources.empty?
268
+ raise Malformed,
269
+ "#{@name}.#{command.hecks_name} corrects #{event.inspect}, but nothing " \
270
+ "declared on #{@name} ever emits it — corrects names a fact this " \
271
+ "aggregate actually announces, not an aspiration"
272
+ end
273
+
274
+ next unless correction.source[:reverses]
275
+
276
+ own_mutations = command.mutations.reject { |mutation| mutation.op == :corrects }
277
+ if own_mutations.any?
278
+ raise Malformed,
279
+ "#{@name}.#{command.hecks_name} declares both corrects #{event.inspect}, " \
280
+ "reverses: true AND its own sets — reverses: true means the correction " \
281
+ "is DERIVED; write one or the other, never both"
282
+ end
283
+
284
+ derived = sources.flat_map(&:mutations).reject { |mutation| mutation.op == :corrects }
285
+ unsupported = derived.reject { |mutation| inverse_op.key?(mutation.op) }
286
+ if unsupported.any?
287
+ raise Malformed,
288
+ "#{@name}.#{command.hecks_name} corrects #{event.inspect}, reverses: " \
289
+ "true, but the command(s) that emit it use " \
290
+ "#{unsupported.map(&:op).uniq.join(', ')} — not statically invertible " \
291
+ "(set needs the specific prior value, multiply/clamp are lossy) — " \
292
+ "declare the corrective sets by hand instead"
293
+ end
294
+
295
+ derived.each do |mutation|
296
+ command.mutations << Mutation.new(target: mutation.target, op: inverse_op.fetch(mutation.op),
297
+ source: mutation.source)
298
+ end
299
+ end
300
+ end
301
+
302
+ # A query must ask about a field the aggregate actually HAS — the same
303
+ # seal `then_set` gets, closing the same silence: a where over a field
304
+ # nothing declares matches nothing and refuses nothing, forever, on
305
+ # every adapter. Three more silences close with it. A dotted path may
306
+ # reach through the value-object graph but must LAND on a scalar
307
+ # member (QuerySpecification::FieldPath is the one walk every engine
308
+ # now shares) — landing on a value object hands SQL a JSON object
309
+ # where the reference interpreter unwraps a hash. An ordered
310
+ # comparator (lt/gt/gte/lte) must land on a numeric leaf — over text
311
+ # the reference interpreter quietly matches no rows while SQL
312
+ # compares lexicographically. And a :symbol value must name one of
313
+ # the query's own declared arguments, or it resolves to nil at
314
+ # dispatch and matches nothing.
315
+ # `private` above has no effect on a constant (Ruby constants are
316
+ # always resolvable through their lexical scope); kept here anyway,
317
+ # beside the method that actually reads it, for the narrative.
318
+ # rubocop:disable-next Lint/UselessConstantScoping
319
+ ORDERED_COMPARATORS = %i[lt lte gt gte].freeze
320
+
321
+ def seal_query_targets
322
+ query_surfaces.each do |owner, fields, lifecycle, queries|
323
+ queries.each do |query|
324
+ query.wheres.each do |clause|
325
+ seal_query_field(owner, query, fields, lifecycle, clause.field)
326
+ seal_ordered_comparator(owner, query, fields, clause)
327
+ infer_local_query_argument(query, fields, lifecycle, clause)
328
+ seal_query_argument(owner, query, clause.value) unless clause.field.to_s.include?("/")
329
+ end
330
+ seal_query_field(owner, query, fields, lifecycle, query.order_by.field, ordering: true) if query.order_by
331
+ seal_query_argument(owner, query, query.limit&.value)
332
+ seal_query_argument(owner, query, query.offset&.value)
333
+ end
334
+ end
335
+ end
336
+
337
+ def query_surfaces
338
+ [[@name, attributes, @lifecycle, @queries]] +
339
+ @entities.map { |entity| ["#{@name}::#{entity.hecks_name}", entity.attributes, entity.lifecycle, entity.queries] }
340
+ end
341
+
342
+ # `/` CROSSES INTO ANOTHER RECORD, `.` WALKS FIELDS INSIDE THIS
343
+ # ONE (ADR 0025, "References") — the operator answers which
344
+ # kind of path this is now, not a name collision to arbitrate,
345
+ # so a hop is routed to its own method before any `.`-splitting
346
+ # runs at all; `seal_query_hop` below never sees a field this
347
+ # one would also have tried to resolve as a local dotted walk.
348
+ # A closed decision tree over where ONE field can resolve —
349
+ # hop, local scalar, lifecycle field, value object (refused),
350
+ # or nothing (refused) — see the doc comment above (and the
351
+ # method-level comments on the hop/ordering split) for why each
352
+ # branch exists. Every branch already reads as its own named
353
+ # rule; extracting pieces would just relocate them.
354
+ # rubocop:disable-next Metrics/CyclomaticComplexity
355
+ # rubocop:disable-next Metrics/PerceivedComplexity
356
+ def seal_query_field(owner, query, fields, lifecycle, field, ordering: false)
357
+ return seal_query_hop(owner, query, fields, field, ordering: ordering) if field.to_s.include?("/")
358
+
359
+ name, *nested = field.to_s.split(".")
360
+ attribute = fields.find { |candidate| candidate.name.to_s == name }
361
+ if nested.empty? && attribute
362
+ refuse_ambiguous_comparison!(owner, query, field, attribute)
363
+ return
364
+ end
365
+ return if nested.empty? && lifecycle&.field.to_s == name
366
+ return if nested.any? && attribute && scalar_path?(attribute, nested)
367
+
368
+ if nested.any? && attribute && resolves?(attribute, nested)
369
+ raise Malformed,
370
+ "#{owner}.#{query.hecks_name} asks about #{field}, which lands on a " \
371
+ "value object, not a scalar — a dotted query path ends on a scalar " \
372
+ "member, or the engines answer it differently"
373
+ end
374
+
375
+ raise Malformed,
376
+ "#{owner}.#{query.hecks_name} asks about #{field}, which #{owner} " \
377
+ "never declares — a query over a field that does not exist " \
378
+ "matches nothing and refuses nothing"
379
+ end
380
+
381
+ # ORDER BY refuses a hop OUTRIGHT, right here — unlike a WHERE
382
+ # hop (deferred below), this doesn't need the target's shape to
383
+ # answer: an ask is ordered by what its own answering rows
384
+ # hold, and a hop answers with a candidate set, not a sort key
385
+ # (see Runtime::ReferenceHop).
386
+ #
387
+ # A WHERE hop is only RECOGNISED here, and CHECKED LATER. The
388
+ # head names one of this aggregate's own references, which is
389
+ # answerable now — a Reference knows its own target_name at
390
+ # declaration. What it points AT is not: stamp_references has
391
+ # already run by this point, but the chapter (Bluebook, and the
392
+ # owning aggregate's OWN place in it) does not exist yet, so
393
+ # Reference#resolve would answer nil for every target in the
394
+ # file, including ones declared above this one. The tail, and
395
+ # whether the target even exists, are BluebookBuilder's
396
+ # business — see validate_query_hops!, which runs once the
397
+ # chapter is real, for exactly the reason
398
+ # validate_no_bidirectional_references! already gives for
399
+ # living at that same later point.
400
+ def seal_query_hop(owner, query, fields, field, ordering:)
401
+ unless QuerySpecification::HopPath.hop_head?(field, fields)
402
+ raise Malformed,
403
+ "#{owner}.#{query.hecks_name} asks about #{field}, which #{owner} " \
404
+ "never declares — a query over a field that does not exist " \
405
+ "matches nothing and refuses nothing"
406
+ end
407
+
408
+ return unless ordering
409
+
410
+ raise Malformed,
411
+ "#{owner}.#{query.hecks_name} orders by #{field}, which hops through " \
412
+ "a reference — an ask is ordered by what its own answering rows " \
413
+ "hold, and a hop answers with a candidate set, not a sort key"
414
+ end
415
+
416
+ def seal_ordered_comparator(owner, query, fields, clause)
417
+ return unless ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym)
418
+
419
+ # A WHERE clause hopping through a reference with an ordered
420
+ # comparator is legitimate ("client whose balance > 500") —
421
+ # unlike ORDER BY (refused outright in seal_query_field, see
422
+ # its own comment), a where-clause hop answers a real
423
+ # candidate set either way, ordered or not. Deferred for the
424
+ # same reason any other hop is: whether the tail is even
425
+ # numeric is BluebookBuilder#validate_query_hops!'s question
426
+ # to ask of the TARGET's shape, not this aggregate's own.
427
+ return if clause.field.to_s.include?("/") && QuerySpecification::HopPath.hop_head?(clause.field, fields)
428
+
429
+ name, *nested = clause.field.to_s.split(".")
430
+ attribute = fields.find { |candidate| candidate.name.to_s == name }
431
+ return if attribute &&
432
+ QuerySpecification::FieldPath.numeric?(attribute, nested) { |type| declared_value_object(type) }
433
+
434
+ held = attribute ? "holds no number" : "is the lifecycle field, which holds text"
435
+ raise Malformed,
436
+ "#{owner}.#{query.hecks_name} compares #{clause.field} with #{clause.op}, " \
437
+ "but #{clause.field} #{held} — an ordered comparison needs a numeric " \
438
+ "field, and over anything else the adapters answer differently or not at all"
439
+ end
440
+
441
+ def seal_query_argument(owner, query, value)
442
+ return unless value.is_a?(Symbol)
443
+ return if query.attribute(value)
444
+
445
+ raise Malformed,
446
+ "#{owner}.#{query.hecks_name} resolves :#{value} from its arguments, " \
447
+ "but declares no #{value} attribute — an argument that does not exist " \
448
+ "resolves to nil and matches nothing"
449
+ end
450
+
451
+ # A symbolic right-hand side is a query input. When the compared path
452
+ # lands on this owner's declared shape, its type is already known and
453
+ # repeating an `attribute` line inside the query adds no information.
454
+ # Reference hops are resolved only after the whole chapter has been
455
+ # owner-stamped; BluebookBuilder performs the identical inference for
456
+ # those deferred paths.
457
+ def infer_local_query_argument(query, fields, lifecycle, clause)
458
+ name = clause.value
459
+ return unless name.is_a?(Symbol)
460
+ return if query.attribute(name)
461
+ return if clause.field.to_s.include?("/")
462
+
463
+ head, *nested = clause.field.to_s.split(".")
464
+ leaf = if nested.empty? && lifecycle&.field.to_s == head
465
+ Attribute.new(name: name, type: String)
466
+ else
467
+ root = fields.find { |candidate| candidate.name.to_s == head }
468
+ found = root && QuerySpecification::FieldPath.leaf_attribute(root, nested) do |type|
469
+ declared_value_object(type)
470
+ end
471
+ found && Attribute.new(name: name, type: found.type, list: found.list?)
472
+ end
473
+ query.attributes << leaf if leaf
474
+ end
475
+
476
+ # A BARE FIELD NAMING A VALUE OBJECT HAS TO SAY WHICH MEMBER IT
477
+ # MEANS, when more than one could answer. The dotted case above
478
+ # already refuses a path that lands on a value object rather than
479
+ # a scalar; a bare name was returning unconditionally, so
480
+ # `where(frequency: ...)` against a StatementFrequency
481
+ # (cadence, retention_months, paper_fee_cents) compiled — and the
482
+ # engines then disagreed about which member it meant, one taking
483
+ # the FIRST numeric and another declining to unwrap at all.
484
+ #
485
+ # Unambiguous is: exactly one member, whatever its type, or
486
+ # exactly one NUMERIC member among several (Money's `cents`
487
+ # beside its `currency` — the reading every engine already
488
+ # shared, and what the corpus relies on). Anything else names
489
+ # its member with a dotted path, which already works.
490
+ #
491
+ # A list is exempt: `contains` over a `list_of` reads element
492
+ # membership, not a scalar comparison, and has its own agreed
493
+ # reading across the engines.
494
+ def refuse_ambiguous_comparison!(owner, query, field, attribute)
495
+ return if attribute.list?
496
+
497
+ value_object = declared_value_object(attribute.type.to_s)
498
+ return unless value_object
499
+
500
+ members = QuerySpecification::Common::Comparison.ambiguous_members(value_object)
501
+ return if members.empty?
502
+
503
+ raise Malformed,
504
+ "#{owner}.#{query.hecks_name} asks about #{field}, which names #{attribute.type} — " \
505
+ "it has #{members.size} members (#{members.join(', ')}) and no single one a " \
506
+ "comparison can mean; name the member (#{field}.#{members.first})"
507
+ end
508
+
509
+ def scalar_path?(attribute, nested)
510
+ QuerySpecification::FieldPath.scalar_leaf?(attribute, nested) { |type| declared_value_object(type) }
511
+ end
512
+
513
+ def resolves?(attribute, nested)
514
+ !QuerySpecification::FieldPath.leaf_attribute(attribute, nested) { |type| declared_value_object(type) }.nil?
515
+ end
516
+
517
+ def declared_value_object(type_name)
518
+ (@value_objects + closed_sets).find { |shape| shape.hecks_name.to_s == type_name }
519
+ end
520
+ end
521
+ end
522
+ end
523
+ end
524
+ end