hecks 0.3.0 → 1.0.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -10,11 +10,25 @@ require_relative "routing"
10
10
  require_relative "dependency_planning"
11
11
  require_relative "../ports/persistence/execution"
12
12
  require_relative "entity_element"
13
+ require_relative "command_interpreter/argument_gate"
13
14
 
14
15
  module Hecks
15
16
  module Runtime
16
17
  class EntityInterpreter
17
18
  include Interpreting
19
+ # THE SAME PAYLOAD GATE aggregate commands and port operations already
20
+ # run — bug audit H1 (docs/audits/2026-08-10-main-bug-audit.md): this
21
+ # class used to run NEITHER refuse_unknown_arguments NOR
22
+ # refuse_absent_arguments, on a comment claiming "an entity inherits
23
+ # its aggregate's own gate." Nothing on the entity dispatch path ever
24
+ # ran one — confirmed live, `LedgerEntry.Reverse` accepted an
25
+ # unrecognized `bogus_arg:` outright, and dispatching it with no
26
+ # `narrative:` silently overwrote the stored narrative with `nil`
27
+ # (`sets :narrative`'s bare self-referential form reads `args[:narrative]`
28
+ # unconditionally). See `step_refuse_unknown_arguments`/
29
+ # `step_refuse_absent_arguments`, below, for how the shared gate is
30
+ # reused rather than reimplemented.
31
+ include CommandInterpreter::ArgumentGate
18
32
 
19
33
  attr_reader :registry
20
34
 
@@ -23,13 +37,17 @@ module Hecks
23
37
  # spec/vocabulary_conformance_spec.rb the same way CommandInterpreter's
24
38
  # own DISPATCH_ORDER is; see that constant's doc comment for why this is
25
39
  # hand-typed rather than read live off the meta-domain at every dispatch.
26
- # Shorter than the aggregate order for the same reasons the declaration
27
- # itself gives : no refuse_unknown_arguments/refuse_absent_arguments (an
28
- # entity inherits its aggregate's own gate) and no
29
- # assign_creation_attributes (an entity is never created through this
40
+ # `refuse_unknown_arguments`/`refuse_absent_arguments` now lead it, same
41
+ # position `AggregateDispatchOrder` holds them at (H1, above) — the only
42
+ # remaining difference from the aggregate order is no
43
+ # `assign_creation_attributes` (an entity is never created through this
30
44
  # path).
31
45
  DISPATCH_ORDER = Hecks::Vocabulary.symbols("EntityDispatchOrder")
32
46
 
47
+ # Same safety valve as `CommandInterpreter::MAX_STALE_WRITE_RETRIES` —
48
+ # see that constant's own comment.
49
+ MAX_STALE_WRITE_RETRIES = 5
50
+
33
51
  # `instance` is the PARENT aggregate record (what gets saved and
34
52
  # returned) ; `element`/`view` are the entity piece itself — `view`
35
53
  # wraps `element` as it stood at `locate_element`, pre-mutation, and
@@ -56,6 +74,10 @@ module Hecks
56
74
  # comment for the shared reasoning (Dispatcher#dry_run?'s own entry
57
75
  # point). `step_save`/`step_emit` are the only two steps here that
58
76
  # read it either.
77
+ # RETRIES THE WHOLE METHOD BODY on `StaleWrite` — same reasoning as
78
+ # `CommandInterpreter#call`'s own retry: a fresh `ctx`, a fresh
79
+ # `step_hydrate_parent`/`step_locate_element` re-reading current
80
+ # state.
59
81
  def call(domain, aggregate, dotted, legacy_args, route: nil, with: nil, dry_run: false)
60
82
  *entity_names, command_name = dotted.to_s.split(".")
61
83
  if entity_names.empty?
@@ -70,13 +92,24 @@ module Hecks
70
92
  entity: entity.hecks_name, command: command_name.inspect))
71
93
 
72
94
  args = Routing.payload(command, with: with, legacy: legacy_args)
73
- ctx = Context.new(domain, aggregate, entity, entity_names.join("."), command, command_name, args)
74
- ctx.chain = chain
75
- ctx.route = route
76
- ctx.dry_run = dry_run
77
- ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command)
78
- run_dispatch_order(DISPATCH_ORDER, ctx)
79
- [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
95
+ attempt = 0
96
+ begin
97
+ ctx = Context.new(domain, aggregate, entity, entity_names.join("."), command, command_name, args)
98
+ ctx.chain = chain
99
+ ctx.route = route
100
+ ctx.dry_run = dry_run
101
+ ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command)
102
+ # RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
103
+ # `step_hydrate_parent` reads `ctx.repository` without re-fetching.
104
+ ctx.repository = @registry.repository(domain, aggregate)
105
+ lock_id = Identity.best_effort(aggregate, args, route)
106
+ run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
107
+ [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
108
+ rescue StaleWrite
109
+ attempt += 1
110
+ retry if attempt < MAX_STALE_WRITE_RETRIES
111
+ raise
112
+ end
80
113
  end
81
114
 
82
115
  private
@@ -100,6 +133,31 @@ module Hecks
100
133
  end
101
134
  end
102
135
 
136
+ # `extra_identity_heads:` — every entity `ctx.chain` walks through, not
137
+ # just the root aggregate `ArgumentGate` already knows about. A
138
+ # two-hop dispatch (`Handler.Dispatch.Bind`) is addressed by BOTH
139
+ # hops' own identity, each read straight out of `args` by
140
+ # `EntityElement#element_of` — refusing those as unknown would refuse
141
+ # every legitimate nested-entity dispatch there is, the same reasoning
142
+ # `ArgumentGate#refuse_unknown_arguments`'s own header gives for `:id`/
143
+ # the root's `identity_heads`.
144
+ def step_refuse_unknown_arguments(ctx)
145
+ step(:refuse_unknown_arguments) {
146
+ refuse_unknown_arguments(ctx.domain, ctx.aggregate, ctx.command, ctx.args,
147
+ extra_identity_heads: ctx.chain.flat_map(&:identity_heads))
148
+ }
149
+ end
150
+
151
+ # No `aggregate:` exemption to pass — that kwarg exists only for a
152
+ # port operation's own self-address (`ArgumentGate#refuse_absent_
153
+ # arguments`'s own comment); an entity command's chain identity never
154
+ # reaches `command.attributes` in the first place (resolved as
155
+ # addressing above, not as a declared fact), so there is nothing here
156
+ # for the exemption to need to strip.
157
+ def step_refuse_absent_arguments(ctx)
158
+ step(:refuse_absent_arguments) { refuse_absent_arguments(ctx.command, ctx.args) }
159
+ end
160
+
103
161
  def step_normalize_args(ctx)
104
162
  ctx.args = step(:normalize_args) { normalize_args(ctx.aggregate, ctx.command, ctx.args) }
105
163
  end
@@ -113,7 +171,8 @@ module Hecks
113
171
  end
114
172
 
115
173
  def step_hydrate_parent(ctx)
116
- ctx.repository = @registry.repository(ctx.domain, ctx.aggregate)
174
+ # `ctx.repository` is resolved once, in `#call`, before the
175
+ # isolation decision — not here any more.
117
176
  ctx.instance = step(:hydrate_parent) {
118
177
  parent(ctx.repository, ctx.aggregate, ctx.entity_name, ctx.command_name, ctx.args, ctx.route)
119
178
  }
@@ -184,8 +243,20 @@ module Hecks
184
243
 
185
244
  step(:save) do
186
245
  @rules.resolve_state_references(ctx.domain, ctx.aggregate, ctx.instance.state)
187
- ctx.repository.save(ctx.instance)
188
- ctx.persistence_outcome = Ports::Persistence::Outcome.new(status: :saved, instance: ctx.instance)
246
+ # `expected_version:` — see CommandInterpreter#step_save's own
247
+ # comment: nil for a repository that isn't CAS-capable, or an
248
+ # instance never read from storage, either of which falls
249
+ # through to a plain save inside `AppendOnly#save`.
250
+ ctx.persistence_outcome = ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
251
+ if ctx.persistence_outcome.status == :stale
252
+ # NOT a `RefusalWording.render` call — see
253
+ # `CommandInterpreter#step_save`'s identical branch and
254
+ # `Runtime::StaleWrite`'s own comment.
255
+ raise(StaleWrite,
256
+ "#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
257
+ "(#{Identity.reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
258
+ "another write committed against this record after it was read")
259
+ end
189
260
  end
190
261
  end
191
262
 
@@ -51,6 +51,28 @@ module Hecks
51
51
  # itself, not whether the caller actually holds `policy`. See
52
52
  # Runtime::TenantScope.
53
53
  class Unauthorized < StandardError; end
54
+ # `corrects` names a past event this record must have already emitted
55
+ # (CommandBuilder#corrects_impl's own comment) — a fact the expression
56
+ # evaluator cannot check (it is not a predicate over the record's OWN
57
+ # fields, it is "did this exact record ever announce this"), so it is
58
+ # raised structurally, the same way AlreadyExists/NotFound are, rather
59
+ # than being expressible as an ordinary `given`. Raised by
60
+ # `CommandRules::Admissibility#enforce_correction_target`.
61
+ class NothingToCorrect < StandardError; end
62
+
63
+ # A RUNTIME FAULT, NOT A DOMAIN REFUSAL — deliberately absent from
64
+ # `DOMAIN_REFUSALS` below and from `vocabulary.bluebook`'s own
65
+ # `DomainRefusal` list. Raised when an optimistic-concurrency CAS write
66
+ # (`AppendOnly#save`'s `expected_version:`) finds the stored version has
67
+ # moved since this instance was read — someone else's write committed
68
+ # in between. `CommandInterpreter#call`/`EntityInterpreter#call` catch
69
+ # this themselves and retry the whole dispatch from a fresh hydrate, so
70
+ # `enforce_givens` re-evaluates against the now-current state; a caller
71
+ # only ever sees this escape when every retry is exhausted under
72
+ # sustained contention — an operational condition (many concurrent
73
+ # writers hammering one aggregate), not a business rule a domain author
74
+ # declared. See docs/decisions/ (concurrency-control ADR).
75
+ class StaleWrite < StandardError; end
54
76
 
55
77
  # A Lambda-routed domain's own refusal (rust/host, `Runtime::
56
78
  # RemoteDispatcher`), carrying Rust's own refusal text verbatim —
@@ -23,6 +23,15 @@ module Hecks
23
23
  module Identity
24
24
  module_function
25
25
 
26
+ # A hash read that decides which spelling of a key answers by
27
+ # PRESENCE, never by `||` — a bare `||` treats a genuinely-held
28
+ # `false` the same as an absent key and falls through to the other
29
+ # spelling, landing on `nil` instead of the real, stored answer.
30
+ def hash_lookup(hash, key)
31
+ sym = key.to_sym
32
+ hash.key?(sym) ? hash[sym] : hash[key]
33
+ end
34
+
26
35
  # The head names the ATTRIBUTE and is consumed by whoever looked the value
27
36
  # up; what is left is the walk down into it. A path with no fields to walk
28
37
  # — an aggregate that declares no identity and falls back to `id` — hands
@@ -32,7 +41,7 @@ module Hecks
32
41
  return held if fields.empty?
33
42
 
34
43
  fields.reduce(Value.materialize(held)) do |dug, field|
35
- dug.is_a?(Hash) ? (dug[field.to_sym] || dug[field]) : nil
44
+ dug.is_a?(Hash) ? hash_lookup(dug, field) : nil
36
45
  end
37
46
  end
38
47
 
@@ -83,7 +92,7 @@ module Hecks
83
92
  # arrived whole has to be opened.
84
93
  return held.to_s unless held.is_a?(Hash)
85
94
 
86
- return rest.reduce(held) { |h, f| h.is_a?(Hash) ? (h[f.to_sym] || h[f]) : nil }&.to_s
95
+ return rest.reduce(held) { |h, f| h.is_a?(Hash) ? hash_lookup(h, f) : nil }&.to_s
87
96
  end
88
97
 
89
98
  # Coerced against the identity ATTRIBUTE only when the caller actually
@@ -123,6 +132,25 @@ module Hecks
123
132
  def reading(construct)
124
133
  construct.identity_paths.join(", ")
125
134
  end
135
+
136
+ # BEST-EFFORT, FOR A LOCK KEY ONLY — `Runtime::AggregateLock`'s own
137
+ # per-record striping needs SOME id to key on before dispatch has run
138
+ # far enough to hydrate for real, so this walks the identical chain
139
+ # `CommandInterpreter#hydrate_existing`/`#hydrate_prior_or_initial`
140
+ # and `EntityInterpreter#parent` already use to locate the real
141
+ # record — but wrapped to never raise. Choosing which Mutex to hold
142
+ # must never itself become a crash. `nil` means "could not resolve
143
+ # from the raw, pre-normalized payload this runs against" — the
144
+ # caller locks by aggregate type alone in that case (coarser, still
145
+ # correct, just less concurrent).
146
+ def best_effort(construct, args, route = nil, reference_key: nil)
147
+ route&.aggregate ||
148
+ of(construct, args) ||
149
+ from(construct, args, :id) ||
150
+ (reference_key && from(construct, args, reference_key))
151
+ rescue StandardError
152
+ nil
153
+ end
126
154
  end
127
155
  end
128
156
  end
@@ -1,16 +1,34 @@
1
1
  require_relative "value"
2
+ require_relative "identity"
2
3
 
3
4
  module Hecks
4
5
  module Runtime
5
6
  class Instance
6
7
  attr_reader :aggregate, :id
7
8
  attr_accessor :state
8
-
9
- def initialize(aggregate:, id:, state: nil)
9
+ # OUT-OF-BAND ADAPTER BOOKKEEPING, NOT DOMAIN STATE — the optimistic-
10
+ # concurrency version a CAS-capable adapter (Postgres today) stamps
11
+ # on a record it reads/writes, so a later `save` can assert "commit
12
+ # only if nobody has written since". Deliberately absent from
13
+ # `to_h`/`[]`/`[]=`/`method_missing` : a domain author never declares
14
+ # this, a `given`/`ensures`/`invariant` can never read it, and no
15
+ # adapter that doesn't understand it (Memory, Heki) ever sets it —
16
+ # `nil` there just means "no CAS attempted", which is exactly what a
17
+ # plain `save` already does. See docs/decisions/ (concurrency-control
18
+ # ADR) for the full mechanism.
19
+ attr_accessor :version
20
+
21
+ # `args:` — THE ORIGINAL COMMAND PAYLOAD, offered only by a fresh
22
+ # creation (`CommandInterpreter#hydrate_legacy_creation`/
23
+ # `#hydrate_complete_state`/`#hydrate_prior_or_initial`, each already
24
+ # holding it when they mint a brand-new record). See
25
+ # `materialize_identity!` for why a composite identity needs it.
26
+ def initialize(aggregate:, id:, state: nil, args: nil)
10
27
  @aggregate = aggregate
11
28
  @id = id
12
29
  @state = state ? self.class.hydrate_with_defaults(aggregate, state) : self.class.defaults(aggregate)
13
- materialize_identity!
30
+ @version = nil
31
+ materialize_identity!(args)
14
32
  end
15
33
 
16
34
  # Loading existing state runs the same default-fill a fresh instance
@@ -103,7 +121,29 @@ module Hecks
103
121
 
104
122
  private
105
123
 
106
- def materialize_identity!
124
+ # M17 — a COMPOSITE identity (`identity_heads.size > 1`, e.g.
125
+ # `identified_by :branch_code, :box_number`) has no single
126
+ # `identified_by` to fall back to `:id` for — `@aggregate.identified_by`
127
+ # is nil the moment there is more than one head (`Behaviour::Identified
128
+ # #derive_identity`), so the single-head branch below never runs for
129
+ # it at all. A creating command that declares those heads as ordinary
130
+ # attributes but doesn't ALSO `sets` them (redundant with the identity
131
+ # the command's own args already named) used to persist every head as
132
+ # nil — the id correctly named the record, but the record's own
133
+ # attributes forgot what named it.
134
+ #
135
+ # Filled from `args`, never from splitting `@id` back apart — the
136
+ # same reason the single-head branch below won't guess a multi-path
137
+ # identifier from its joined string: `@id` is a display key, not a
138
+ # reversible serialization, and a composite's own separator can
139
+ # collide with a part's own text. `args` is only offered by a FRESH
140
+ # creation (`Instance.new`'s own `args:` comment); an existing record
141
+ # read back from storage has no args to lean on, and doesn't need
142
+ # one since a correctly-persisted record already carries its own
143
+ # heads.
144
+ def materialize_identity!(args = nil)
145
+ return materialize_composite_identity!(args) if @aggregate.identity_heads.size > 1
146
+
107
147
  identity = @aggregate.identified_by || :id
108
148
  attribute = @aggregate.attribute(identity)
109
149
  return unless attribute && @state[identity].nil?
@@ -115,6 +155,21 @@ module Hecks
115
155
 
116
156
  @state[identity] = Value.from_identifier(@aggregate, attribute, @id)
117
157
  end
158
+
159
+ def materialize_composite_identity!(args)
160
+ return unless args
161
+
162
+ @aggregate.identity_paths.each do |path|
163
+ head = path.to_s.split(".").first.to_sym
164
+ attribute = @aggregate.attribute(head)
165
+ next unless attribute && @state[head].nil?
166
+
167
+ raw = Identity.from(@aggregate, args, path, value_owner: @aggregate)
168
+ next if raw.nil?
169
+
170
+ @state[head] = Value.from_identifier(@aggregate, attribute, raw)
171
+ end
172
+ end
118
173
  end
119
174
  end
120
175
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "value"
2
+ require_relative "aggregate_lock"
2
3
 
3
4
  module Hecks
4
5
  module Runtime
@@ -43,6 +44,26 @@ module Hecks
43
44
  order.each { |name| send(:"step_#{name}", ctx) }
44
45
  end
45
46
 
47
+ # THE CONCURRENCY-CONTROL SPLIT — see docs/decisions/ (concurrency
48
+ # control ADR) for the full mechanism. A repository that declares
49
+ # `:optimistic_concurrency` (Postgres today) already closes the
50
+ # lost-update gap itself, via `step_save`'s CAS + `#call`'s own
51
+ # `StaleWrite` retry loop — an extra in-process lock here would be
52
+ # pointless overhead, not incorrect, so it's skipped for clarity.
53
+ # Every other repository (Heki, Memory — confirmed process-local
54
+ # data, never a second process writing the same store) gets a
55
+ # striped `Mutex` held for the WHOLE dispatch-order run, so a second
56
+ # thread's own hydrate can't start until the first thread's save has
57
+ # landed. `lock_key_id` is best-effort (`Identity.best_effort`) —
58
+ # `nil` still locks correctly, just coarser (by aggregate type).
59
+ def run_dispatch_order_with_isolation(order, ctx, lock_key_id:)
60
+ if ctx.repository.capabilities.include?(:optimistic_concurrency)
61
+ run_dispatch_order(order, ctx)
62
+ else
63
+ AggregateLock.for(ctx.domain, ctx.aggregate, lock_key_id).synchronize { run_dispatch_order(order, ctx) }
64
+ end
65
+ end
66
+
46
67
  # Every declared attribute present in the payload passes the reference
47
68
  # gate, then coercion — the same walk whether the command acts on an
48
69
  # aggregate or on one of its entity's elements.
@@ -1,8 +1,9 @@
1
1
  require_relative "../facade/surface"
2
2
  require_relative "../ports/loading"
3
+ require_relative "../ports/persistence"
3
4
  require_relative "dispatcher"
4
5
  require_relative "remote_dispatcher"
5
- require_relative "era_check"
6
+ require_relative "boot_gates"
6
7
  require_relative "registry"
7
8
 
8
9
  module Hecks
@@ -42,20 +43,7 @@ module Hecks
42
43
  loading.load_domain(directory, environment: environment)
43
44
  end
44
45
 
45
- # The era gate runs BEFORE verify! builds repositories: minting an
46
- # era (Postgres) must have created its partition and head views
47
- # before any adapter opens them, and a refused era must refuse
48
- # before any adapter touches data.
49
- EraCheck.check!(registry, directory)
50
- registry.verify!
51
- # AFTER verify! (conservative — any wiring error surfaces first,
52
- # not strictly required since resolution only needs the
53
- # hecksagon binds, already loaded), BEFORE the dispatcher is
54
- # built — repopulates `saga_instances` from whatever durable
55
- # store each domain's own adapter answers with (§2-§4), so a
56
- # process manager mid-flight at the last shutdown/crash/cold-
57
- # start doesn't start this boot looking like it never began.
58
- registry.rehydrate_sagas!
46
+ run_boot_gates!(registry, directory)
59
47
  dispatcher = dispatcher_for(registry)
60
48
  install_facade ? bind_runtime(dispatcher) : dispatcher
61
49
  end
@@ -95,13 +83,66 @@ module Hecks
95
83
  loading.load_selected(files, environment: environment)
96
84
  end
97
85
 
98
- EraCheck.check!(registry, directory)
99
- registry.verify!
100
- registry.rehydrate_sagas!
86
+ run_boot_gates!(registry, directory)
101
87
  dispatcher = dispatcher_for(registry)
102
88
  install_facade ? bind_runtime(dispatcher) : dispatcher
103
89
  end
104
90
 
91
+ # ADR 0031 — replaces two previously-hardcoded, unconditional calls
92
+ # with a per-boot `BootGates` instance holding exactly the gates THIS
93
+ # registry's own bound adapters have a capability for. Ordering is
94
+ # preserved: era-checking (when a persistence plugin contributes one)
95
+ # still runs before `verify!`, saga rehydration still runs after
96
+ # (conservative — see `SagaPersistence#rehydrate_sagas!`'s own
97
+ # comment).
98
+ #
99
+ # ADR 0033 — this loader no longer names `EraCheck`, or any other
100
+ # era-specific class, at all. Every LOADED persistence plugin
101
+ # (`Ports::Persistence.each_plugin` — nothing here if nothing was
102
+ # ever `require`d) is asked to contribute its own `:pre_verify`/
103
+ # `:post_verify` gates generically; `:saga_rehydration` is the one
104
+ # gate core still registers directly, because ADR 0031 already
105
+ # proved it's not era-specific.
106
+ def self.run_boot_gates!(registry, directory)
107
+ gates = BootGates.new
108
+ Ports::Persistence.each_plugin { |plugin| plugin.contribute_boot_gates(registry, gates) }
109
+ check_compute_rules_backstop!(registry)
110
+
111
+ gates.run!(:pre_verify, registry, directory)
112
+ registry.verify!
113
+
114
+ gates.register(:saga_rehydration, ->(reg, _dir) { reg.rehydrate_sagas! }, phase: :post_verify) if
115
+ registry.hecksagons.each_key.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
116
+ gates.run!(:post_verify, registry, directory)
117
+ gates
118
+ end
119
+
120
+ # The one piece of the old, era-owned `check_compute_rules!` core
121
+ # still carries — deliberately thinner. `registry.translations` is
122
+ # plain `Bluebook::Translation`/`TranslationAggregate`/
123
+ # `TranslationCompute`/`TranslationRekey` data (`bluebook/
124
+ # translation.rb`, core, no era-specific class involved), so this
125
+ # needs nothing plugin-specific to ask "does anything declare a
126
+ # compute/rekey rule at all." A LOADED persistence plugin (e.g. the
127
+ # era plugin's own `:era_compute_rules` gate, registered above) runs
128
+ # the real, adapter-aware version of this check and refuses by name
129
+ # ("...is bound to Memory") long before this ever would; this only
130
+ # fires when nothing did, because nothing was loaded to.
131
+ def self.check_compute_rules_backstop!(registry)
132
+ return if Ports::Persistence.plugins_loaded?
133
+
134
+ registry.translations.each do |translation|
135
+ translation.aggregates.each do |aggregate|
136
+ next if aggregate.computes.empty? && aggregate.rekeys.empty?
137
+
138
+ raise WiringError,
139
+ "cannot boot #{translation.domain}::#{aggregate.name}: a compute/rekey rule is declared, but no " \
140
+ "persistence plugin that can interpret it is loaded (e.g. require " \
141
+ "\"hecks/ports/persistence/plugins/era\")"
142
+ end
143
+ end
144
+ end
145
+
105
146
  # `RemoteDispatcher` for a domain routed through Lambda,
106
147
  # `Dispatcher` otherwise — the ONE place this decision gets
107
148
  # made, so everything built on top (`Handle`, `AggregateDoor`,
@@ -177,11 +177,26 @@ module Hecks
177
177
 
178
178
  ordered = ordered_elements(rows, declared.order_by, declared.null_semantics,
179
179
  parent_key, entity.identity_heads)
180
- declared.limit ? ordered.first(resolve_query_value(declared.limit.value, args).to_i) : ordered
180
+ # OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
181
+ # #interpret's own rows above. `entity_rows` is the ONLY engine
182
+ # for entity/sub-list queries, so a declared offset here silently
183
+ # vanished for every entity query, not merely one path among
184
+ # several.
185
+ skipped = declared.offset ? ordered.drop(resolve_query_value(declared.offset.value, args).to_i) : ordered
186
+ declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
181
187
  end
182
188
 
189
+ # FieldPath.dig, not a raw `element[clause.field.to_sym]` — an
190
+ # entity sub-list row is a plain hash merged from stored state
191
+ # (mixed string/symbol keys depending on adapter, per `#cell`'s own
192
+ # comment below), and a dotted `where` (`where "price.cents" < 100`)
193
+ # needs the same segment-by-segment walk every other query path
194
+ # already gets. Reading only the symbol spelling of the WHOLE
195
+ # dotted string as one key always missed — `element[:"price.cents"]`
196
+ # is never a real key — so a dotted where on an entity query
197
+ # silently matched nothing, on the only engine entity queries have.
183
198
  def element_where_holds?(clause, element, args)
184
- holds?(clause, element[clause.field.to_sym], args)
199
+ holds?(clause, QuerySpecification::FieldPath.dig(element, clause.field), args)
185
200
  end
186
201
 
187
202
  # A row's own key, however the store spells it. A sub-list row is a plain hash
@@ -191,7 +206,13 @@ module Hecks
191
206
  # to remove. It rides `comparable` for the same reason a where-clause does : an
192
207
  # identity is a value object, and `to_s` on one is an OBJECT ADDRESS — a sort key
193
208
  # that differs run to run, which is worse than the store order it replaced.
194
- def cell(row, key) = row[key.to_sym] || row[key.to_s]
209
+ # `key?` decides which spelling answers, never `||` a row whose
210
+ # value is a genuinely-held `false` must not fall through to the
211
+ # other spelling (usually absent) and land on `nil`.
212
+ def cell(row, key)
213
+ sym = key.to_sym
214
+ row.key?(sym) ? row[sym] : row[key.to_s]
215
+ end
195
216
 
196
217
  # A sub-list row is identified by its PARENT and then its own key : two
197
218
  # entities under different parents can share a sequence, so the parent has
@@ -244,10 +265,21 @@ module Hecks
244
265
 
245
266
  def comparable(value) = QuerySpecification::Common::Comparison.comparable(value)
246
267
 
268
+ # FieldPath.dig, not a raw `record[field]` — `record` is an Instance
269
+ # here, and a dotted order_by (`order_by "price.cents"`) is a
270
+ # single symbol key (`:"price.cents"`) that never matches anything
271
+ # `Instance#[]` actually holds, so a dotted order_by silently sorted
272
+ # by all-nil (the identity tier alone deciding every tie) on this,
273
+ # the reference/no-native-hook engine — the same bug already fixed
274
+ # for `where` (see `where_holds?` above) and for entity rows (see
275
+ # `ordered_elements` below), just not yet for this, the aggregate-
276
+ # level order_by.
247
277
  def ordered(records, order_by, null_semantics = nil)
248
278
  field = order_by&.field
249
279
  Ports::Query::Ordering.apply(records, order_by, null_semantics,
250
- identity: ->(record) { record.id.to_s }) { |record| comparable(record[field]) }
280
+ identity: ->(record) { record.id.to_s }) { |record|
281
+ comparable(QuerySpecification::FieldPath.dig(record, field))
282
+ }
251
283
  end
252
284
  end
253
285
  end
@@ -52,8 +52,16 @@ module Hecks
52
52
  visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
53
53
  unless visible
54
54
  names = normalized_scopes.map(&:name).join(" then ")
55
+ # WHAT IS VISIBLE, NAMED. A refusal that only says which name is
56
+ # missing sent a modeler guessing field after field
57
+ # (`number`, `reference`…) at a fan-out row that is addressed
58
+ # by ONE key — `account`, the lowercase aggregate — which
59
+ # nothing else in the domain spells out. The names each scope
60
+ # actually offers are the whole diagnosis; the refusal now
61
+ # lists them, scope by scope.
62
+ offered = normalized_scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
55
63
  raise UnknownArgument,
56
- "#{label}'s with: reads :#{source}, which is not visible in #{names}"
64
+ "#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
57
65
  end
58
66
  visible.facts.fetch(source)
59
67
  end
@@ -68,7 +68,7 @@ module Hecks
68
68
  root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
69
69
  projected = []
70
70
  rows_by_as = {}
71
- (root_heads + other_heads).each do |head|
71
+ (root_heads + order_other_heads(bluebook, root_heads, other_heads)).each do |head|
72
72
  rows = if head[:aggregate] == model.reference_target
73
73
  [fetch(bluebook, domain, head[:aggregate], reference_id)]
74
74
  elsif rootless
@@ -112,6 +112,81 @@ module Hecks
112
112
  end]
113
113
  end
114
114
 
115
+ # THE ROOT-FIRST FIX'S OWN FIX — root-first alone only reaches one
116
+ # level: it guarantees the root is in `projected` before any other
117
+ # head is matched, but a CHAIN of non-root heads (a head that
118
+ # references another non-root head, not the root) is still
119
+ # matched against whatever declaration order happened to put in
120
+ # `projected` so far. `include Coupon` before `include Promotion`
121
+ # on a read model rooted at Item, where Coupon references
122
+ # Promotion (which references Item), silently returned an empty
123
+ # `coupons` array — Coupon's match ran while `projected` held only
124
+ # Item, one level short of what it needed.
125
+ #
126
+ # Fixed the same way root-first was: not by asking bluebook authors
127
+ # to declare `include` in dependency order (the same promise
128
+ # `read_model_builder.rb` already makes and this file is the one
129
+ # place obligated to keep), but by topologically sorting the
130
+ # non-root heads on their OWN declared reference fields before
131
+ # this method's runtime matching ever runs — Kahn's algorithm,
132
+ # picking ready heads in DECLARED order at each step so declaring
133
+ # order still governs whenever there is no dependency to break a
134
+ # tie. This generalizes root-first (a chain of length 1) to a
135
+ # chain of any depth, and to a head depending on more than one
136
+ # other head at once (not just a straight chain).
137
+ #
138
+ # A cycle among non-root heads (A references B which references A)
139
+ # has no valid topological order at all — falls back to the
140
+ # remaining heads' declared order rather than looping forever, the
141
+ # same "whichever runs first finds nothing" behaviour this method
142
+ # had for every non-root head before root-first existed.
143
+ def order_other_heads(bluebook, root_heads, other_heads)
144
+ resolved = root_heads.map { |head| head[:aggregate] }
145
+ remaining = other_heads.dup
146
+ ordered = []
147
+ until remaining.empty?
148
+ ready, blocked = remaining.partition do |head|
149
+ depends_on(bluebook, head, other_heads).all? { |target| resolved.include?(target) }
150
+ end
151
+ if ready.empty?
152
+ ordered.concat(remaining)
153
+ break
154
+ end
155
+ ordered.concat(ready)
156
+ resolved.concat(ready.map { |head| head[:aggregate] })
157
+ remaining = blocked
158
+ end
159
+ ordered
160
+ end
161
+
162
+ # Which OTHER declared (non-root) heads a head's own aggregate
163
+ # holds a reference field toward — the same relationship this
164
+ # file's runtime matching checks record-by-record, asked here
165
+ # statically, once, to order heads before any record is read.
166
+ #
167
+ # `head[:aggregate]` names whatever `include` was given — and
168
+ # `include` accepts a nested ENTITY (Member, nested under
169
+ # ValueObject ; Handler and Dispatch, nested under ProcessManager
170
+ # — bluebook.bluebook's own `WholeBluebook` read model includes
171
+ # all three) just as readily as a top-level aggregate.
172
+ # `bluebook.aggregate` only ever finds the latter
173
+ # (Behaviour::Chapter#aggregate searches `@aggregates`, which
174
+ # holds no entities), so it returns nil for an entity-headed
175
+ # include — a real case, not a malformed one. `records`, below,
176
+ # already treats that nil as "no rows of its own to fetch" ;
177
+ # a head with no rows of its own has nothing to check for a
178
+ # reference field either, so it depends on nothing here, the
179
+ # same as it always silently read empty before this file's
180
+ # topological sort existed.
181
+ def depends_on(bluebook, head, other_heads)
182
+ aggregate = bluebook.aggregate(head[:aggregate])
183
+ return [] unless aggregate
184
+
185
+ other_heads.reject { |other| other[:aggregate] == head[:aggregate] }
186
+ .select { |other| reference_fields(aggregate, other[:aggregate]).any? }
187
+ .map { |other| other[:aggregate] }
188
+ end
189
+
115
190
  # `group_by`'s own declared fields, checked against the ONE
116
191
  # many-side head they apply to (`seal_group_by` already refuses
117
192
  # zero or several) — resolved here, once, rather than re-derived
@@ -70,6 +70,8 @@ module Hecks
70
70
  "{type} is a composite identity — an identity must have exactly one field",
71
71
  %w[TypeMismatch numeric_field] =>
72
72
  "{type}.{field} expects {expected}, got {offered}",
73
+ %w[TypeMismatch non_finite_field] =>
74
+ "{type}.{field} must be a finite number, got {offered}",
73
75
  %w[TypeMismatch pattern_mismatch] =>
74
76
  "{type}.{field} must match {pattern}, got {offered}",
75
77
  %w[TypeMismatch arithmetic_amount] =>