hecks 0.3.0 → 1.0.1

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 (202) 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/sql_query_builder.rb +1 -1
  14. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  15. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  16. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  17. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  18. data/lib/hecks/adapters/driven.rb +19 -1
  19. data/lib/hecks/behaviors/dsl.rb +29 -0
  20. data/lib/hecks/behaviors/expectations.rb +62 -2
  21. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  22. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  23. data/lib/hecks/bluebook/assembly.rb +14 -1
  24. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  25. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  26. data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
  27. data/lib/hecks/bluebook/chapter.rb +21 -11
  28. data/lib/hecks/bluebook/command.rb +1 -1
  29. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  30. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  31. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  32. data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
  33. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
  34. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  35. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  36. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  37. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  39. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  40. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  41. data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
  42. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  43. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  44. data/lib/hecks/bluebook/expression/projection.json +48 -0
  45. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  46. data/lib/hecks/bluebook/expression.rb +1 -0
  47. data/lib/hecks/bluebook/hexagon.rb +1 -1
  48. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  49. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  50. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  51. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  52. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  53. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  54. data/lib/hecks/bluebook/model_check.rb +147 -6
  55. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  56. data/lib/hecks/bluebook/process_manager.rb +53 -11
  57. data/lib/hecks/bluebook/project_register.rb +25 -0
  58. data/lib/hecks/bluebook/value_object.rb +19 -2
  59. data/lib/hecks/doc/reference.rb +22 -1
  60. data/lib/hecks/facade/cli_door.rb +6 -3
  61. data/lib/hecks/facade/json_door.rb +16 -4
  62. data/lib/hecks/forms/app.rb +47 -6
  63. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  64. data/lib/hecks/forms/field_renderer.rb +11 -4
  65. data/lib/hecks/forms/html.rb +31 -0
  66. data/lib/hecks/forms/params.rb +30 -1
  67. data/lib/hecks/forms/port_argument.rb +46 -0
  68. data/lib/hecks/forms/record_renderer.rb +6 -2
  69. data/lib/hecks/forms/record_table.rb +6 -1
  70. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  71. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  72. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  73. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  74. data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
  75. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  76. data/lib/hecks/fuzzing/properties.rb +52 -6
  77. data/lib/hecks/fuzzing/replay.rb +51 -18
  78. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  79. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  80. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  81. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  82. data/lib/hecks/grammar/evolve.rb +33 -0
  83. data/lib/hecks/grammar/expression.bluebook +8 -5
  84. data/lib/hecks/grammar/expression_operators.json +497 -1
  85. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  86. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  87. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  88. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  89. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  90. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  91. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  92. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  93. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  94. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  95. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  96. data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
  97. data/lib/hecks/language/oidc.json +20 -0
  98. data/lib/hecks/language/port.bluebook +30 -2
  99. data/lib/hecks/naming.rb +54 -1
  100. data/lib/hecks/ports/access_control.port +7 -2
  101. data/lib/hecks/ports/access_control.rb +1 -1
  102. data/lib/hecks/ports/agent.port +6 -2
  103. data/lib/hecks/ports/agent.rb +1 -1
  104. data/lib/hecks/ports/authentication.port +4 -2
  105. data/lib/hecks/ports/authentication.rb +1 -1
  106. data/lib/hecks/ports/authorization.port +5 -2
  107. data/lib/hecks/ports/authorization.rb +14 -11
  108. data/lib/hecks/ports/clock.port +3 -2
  109. data/lib/hecks/ports/clock.rb +1 -1
  110. data/lib/hecks/ports/extraction.port +3 -2
  111. data/lib/hecks/ports/extraction.rb +1 -1
  112. data/lib/hecks/ports/identity_assignment.port +3 -2
  113. data/lib/hecks/ports/identity_assignment.rb +1 -1
  114. data/lib/hecks/ports/identity_generation.port +3 -2
  115. data/lib/hecks/ports/identity_generation.rb +1 -1
  116. data/lib/hecks/ports/identity_resolution.port +3 -2
  117. data/lib/hecks/ports/identity_resolution.rb +1 -1
  118. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  119. data/lib/hecks/ports/persistence/execution.rb +6 -1
  120. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  121. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  122. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  123. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  129. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  130. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  131. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  132. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  133. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  134. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  135. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  136. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  137. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  138. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  139. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  140. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  141. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  142. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  143. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  144. data/lib/hecks/ports/persistence.rb +1 -1
  145. data/lib/hecks/ports/projection.rb +20 -0
  146. data/lib/hecks/projections/diagrams.rb +230 -1
  147. data/lib/hecks/projections/shape.rb +15 -2
  148. data/lib/hecks/projector/cli_projector.rb +10 -1
  149. data/lib/hecks/projector/exporter.rb +19 -2
  150. data/lib/hecks/query_ir.rb +19 -0
  151. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  152. data/lib/hecks/query_specification/field_path.rb +20 -2
  153. data/lib/hecks/query_specification/hop_path.rb +7 -5
  154. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  155. data/lib/hecks/runtime/boot_gates.rb +41 -0
  156. data/lib/hecks/runtime/caller.rb +19 -3
  157. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  158. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  159. data/lib/hecks/runtime/command_interpreter.rb +165 -34
  160. data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
  161. data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
  162. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  163. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  164. data/lib/hecks/runtime/dependency_planning.rb +66 -5
  165. data/lib/hecks/runtime/dispatcher.rb +19 -4
  166. data/lib/hecks/runtime/entity_interpreter.rb +92 -14
  167. data/lib/hecks/runtime/errors.rb +22 -0
  168. data/lib/hecks/runtime/identity.rb +30 -2
  169. data/lib/hecks/runtime/instance.rb +59 -4
  170. data/lib/hecks/runtime/interpreting.rb +21 -0
  171. data/lib/hecks/runtime/loader.rb +59 -18
  172. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  173. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  174. data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
  175. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  176. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  177. data/lib/hecks/runtime/registry/verification.rb +88 -0
  178. data/lib/hecks/runtime/registry.rb +69 -8
  179. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  180. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  181. data/lib/hecks/runtime/value/admission.rb +19 -1
  182. data/lib/hecks/runtime/value/coercion.rb +75 -10
  183. data/lib/hecks/runtime.rb +17 -5
  184. data/lib/hecks/storehouse.rb +632 -0
  185. data/lib/hecks/version.rb +1 -1
  186. data/lib/hecks/vocabulary.rb +6 -1
  187. data/lib/hecks.rb +28 -3
  188. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  189. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  190. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  191. metadata +49 -37
  192. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  193. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  194. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  195. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  196. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  197. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  198. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  199. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  200. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  201. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  202. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -62,13 +62,51 @@ module Hecks
62
62
  class Analyzer
63
63
  STATEFUL_MUTATIONS = %i[append increment decrement multiply clamp remove].freeze
64
64
 
65
- def self.call(aggregate:, command:) = new(aggregate, command).call
66
-
67
- def initialize(aggregate, command)
65
+ # `root_aggregate:` Wave 8's own audit surfaced a real bug here,
66
+ # not merely a missing feature: for an ENTITY-owned command,
67
+ # `EntityInterpreter` calls this with `aggregate:` set to the
68
+ # ENTITY itself (`element_interpreter.rb`'s own `Analyzer.call
69
+ # (aggregate: entity, command:)`), so `owner_fields` was always
70
+ # the entity's own attribute set. A `given`/`ensures` reading
71
+ # `parent.X` legitimately means the ROOT aggregate's own field —
72
+ # a genuinely different owner — but `classify_path`'s `:parent`
73
+ # branch checked that read against `owner_fields` (the entity's),
74
+ # which can never contain a root-level field, so every entity
75
+ # command with a real, legitimate `parent.*` read was refused as
76
+ # unresolved regardless of correctness. Defaults to `aggregate`
77
+ # (a no-op) for the plain-aggregate case — `CommandInterpreter`'s
78
+ # own call site never needed to change.
79
+ def self.call(aggregate:, command:, root_aggregate: aggregate) = new(aggregate, command, root_aggregate).call
80
+
81
+ def initialize(aggregate, command, root_aggregate = aggregate)
68
82
  @aggregate = aggregate
69
83
  @command = command
70
84
  @owner_fields = aggregate.attributes.to_set(&:name)
71
85
  @owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
86
+ @root_owner_fields = root_aggregate.attributes.to_set(&:name)
87
+ @root_owner_fields << root_aggregate.lifecycle.field.to_sym if root_aggregate.lifecycle
88
+ # `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
89
+ # `given`/`ensures` reading one (e.g. `customer_status ==
90
+ # "active"`) is reading this record's own stored field, same
91
+ # as any attribute, even though nothing here WRITES it via a
92
+ # declared mutation (`CommandInterpreter#seed_projected_fields`
93
+ # populates it outside this analysis entirely). Left out of
94
+ # `known_writes` deliberately: `add_preservation_reads` then
95
+ # correctly treats it as a prior-state read that must survive
96
+ # a partial mutation, which is exactly right — a projected
97
+ # field's freshness comes from the interpreter reseeding it on
98
+ # save, not from anything a caller-supplied write set carries.
99
+ # Applies to BOTH `owner_fields` and `root_owner_fields` — an
100
+ # entity's own `parent.*` read can name the root aggregate's
101
+ # projected field just as easily as one of its real attributes
102
+ # (`Banking::Withdrawal.Dispute`'s own `parent.account_customer_
103
+ # status`, ATMCard's projected field, is a real, live example).
104
+ if aggregate.respond_to?(:projected_fields)
105
+ aggregate.projected_fields.each { |field| @owner_fields << field.name }
106
+ end
107
+ if root_aggregate.respond_to?(:projected_fields)
108
+ root_aggregate.projected_fields.each { |field| @root_owner_fields << field.name }
109
+ end
72
110
  @payload_fields = command.attributes.to_set(&:name)
73
111
  @state_reads = Set.new
74
112
  @payload_reads = Set.new
@@ -101,7 +139,7 @@ module Hecks
101
139
 
102
140
  private
103
141
 
104
- attr_reader :aggregate, :command, :owner_fields, :payload_fields,
142
+ attr_reader :aggregate, :command, :owner_fields, :root_owner_fields, :payload_fields,
105
143
  :state_reads, :payload_reads, :writes, :known_writes, :unresolved
106
144
 
107
145
  # A fresh Instance supplies these values without reading a stored
@@ -190,13 +228,36 @@ module Hecks
190
228
  end
191
229
  end
192
230
 
231
+ # KNOWN, HARMLESS GAP: `corrects ..., as: :name`'s bound name
232
+ # (admissibility.rb's `enforce_correction_target`/`enforce_givens`/
233
+ # `enforce_ensures`) isn't special-cased here the way `:old`/
234
+ # `:parent` are — a given/ensures referencing it falls through to
235
+ # `unresolved` below (its own field lookup finds no owner/payload
236
+ # match), same net effect as any other not-yet-optimized command:
237
+ # `complete_state?` comes back false, so dispatch takes the safe
238
+ # `hydrate_existing` path instead of the `ATOMIC_PUT` fast path.
239
+ # Not a correctness bug — `as:`'s runtime binding (a plain `attrs`
240
+ # merge, exactly like `old:`'s) resolves and evaluates correctly
241
+ # regardless of what this STATIC analysis concludes — just a real,
242
+ # deliberately-left optimization gap: closing it would mean
243
+ # threading "which names this command declares as correction
244
+ # bindings" into the Analyzer, which doesn't have that per-command
245
+ # context today. Worth doing alongside `:old`/`:parent`'s own
246
+ # handling someday, not attempted here.
193
247
  def classify_path(path, phase)
194
248
  head, nested = path.split(".", 2)
195
249
  name = head.to_sym
196
250
 
197
251
  if name == :parent
198
252
  parent_field = nested.to_s.split(".", 2).first
199
- if parent_field.empty? || !owner_fields.include?(parent_field.to_sym)
253
+ # `root_owner_fields` NOT `owner_fields`. For an entity-owned
254
+ # command `owner_fields` is the ENTITY's own attribute set;
255
+ # `parent.X` always means the ROOT aggregate's own field, a
256
+ # genuinely different owner (`root_aggregate:`'s own header,
257
+ # above, has the full bug this fixes). Identical for a plain
258
+ # aggregate command, where root_aggregate defaults to aggregate
259
+ # itself and the two sets are the same set.
260
+ if parent_field.empty? || !root_owner_fields.include?(parent_field.to_sym)
200
261
  unresolved << "#{path} does not name parent aggregate state"
201
262
  else
202
263
  state_reads << parent_field.to_sym
@@ -256,15 +256,30 @@ module Hecks
256
256
  # is cleared for the reaction's own dispatch, so a triggering
257
257
  # caller's role can neither satisfy nor block a reaction command it
258
258
  # has nothing to do with (Runtime::Caller.without).
259
+ #
260
+ # `Thread.current[:hecks_reaction_depth]`, not a plain ivar — this
261
+ # `Dispatcher` instance is a single object shared by every thread
262
+ # dispatching through it (a Puma worker pool, say), so a plain ivar
263
+ # here is exactly the known Puma-concurrency bug class: two
264
+ # concurrent top-level dispatches on different threads would
265
+ # increment/decrement the SAME counter, letting one thread's nested
266
+ # reaction depth leak into another thread's unrelated dispatch. A
267
+ # `Mutex` is not the answer either — a reaction cascade re-enters
268
+ # `reenter` on the SAME thread (see `SagaInterpreter#advance_saga`'s
269
+ # own comment on why a non-reentrant `Mutex` can't guard this).
270
+ # `Thread.current`-backed, saved/restored around the call with a
271
+ # plain local + `ensure`, is the same idiom `Runtime::Caller`
272
+ # (`caller.rb`) already established for exactly this shape of
273
+ # per-thread ambient state.
259
274
  def reenter(verb, saga_correlation: nil, **args)
260
- depth = @reaction_depth.to_i
261
- @reaction_depth = depth + 1
275
+ depth = Thread.current[:hecks_reaction_depth].to_i
276
+ Thread.current[:hecks_reaction_depth] = depth + 1
262
277
  Caller.without { dispatch(verb, saga_correlation: saga_correlation, **args) }
263
278
  ensure
264
- @reaction_depth = depth
279
+ Thread.current[:hecks_reaction_depth] = depth
265
280
  end
266
281
 
267
- def reaction_depth_reached? = @reaction_depth.to_i >= MAX_REACTION_DEPTH
282
+ def reaction_depth_reached? = Thread.current[:hecks_reaction_depth].to_i >= MAX_REACTION_DEPTH
268
283
  def max_reaction_depth = MAX_REACTION_DEPTH
269
284
 
270
285
  private
@@ -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,31 @@ 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
+ # `root_aggregate:` `entity` is the immediate owner (what
102
+ # `owner_fields` inside the Analyzer means), but a `parent.X`
103
+ # read inside this command's own given/ensures means the ROOT
104
+ # aggregate's own field, not the entity's — `aggregate` here IS
105
+ # that root (this method's own first parameter, never the
106
+ # entity). See DependencyPlanning::Analyzer.call's own header
107
+ # for the bug this closes.
108
+ ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command, root_aggregate: aggregate)
109
+ # RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
110
+ # `step_hydrate_parent` reads `ctx.repository` without re-fetching.
111
+ ctx.repository = @registry.repository(domain, aggregate)
112
+ lock_id = Identity.best_effort(aggregate, args, route)
113
+ run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
114
+ [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
115
+ rescue StaleWrite
116
+ attempt += 1
117
+ retry if attempt < MAX_STALE_WRITE_RETRIES
118
+ raise
119
+ end
80
120
  end
81
121
 
82
122
  private
@@ -100,6 +140,31 @@ module Hecks
100
140
  end
101
141
  end
102
142
 
143
+ # `extra_identity_heads:` — every entity `ctx.chain` walks through, not
144
+ # just the root aggregate `ArgumentGate` already knows about. A
145
+ # two-hop dispatch (`Handler.Dispatch.Bind`) is addressed by BOTH
146
+ # hops' own identity, each read straight out of `args` by
147
+ # `EntityElement#element_of` — refusing those as unknown would refuse
148
+ # every legitimate nested-entity dispatch there is, the same reasoning
149
+ # `ArgumentGate#refuse_unknown_arguments`'s own header gives for `:id`/
150
+ # the root's `identity_heads`.
151
+ def step_refuse_unknown_arguments(ctx)
152
+ step(:refuse_unknown_arguments) {
153
+ refuse_unknown_arguments(ctx.domain, ctx.aggregate, ctx.command, ctx.args,
154
+ extra_identity_heads: ctx.chain.flat_map(&:identity_heads))
155
+ }
156
+ end
157
+
158
+ # No `aggregate:` exemption to pass — that kwarg exists only for a
159
+ # port operation's own self-address (`ArgumentGate#refuse_absent_
160
+ # arguments`'s own comment); an entity command's chain identity never
161
+ # reaches `command.attributes` in the first place (resolved as
162
+ # addressing above, not as a declared fact), so there is nothing here
163
+ # for the exemption to need to strip.
164
+ def step_refuse_absent_arguments(ctx)
165
+ step(:refuse_absent_arguments) { refuse_absent_arguments(ctx.command, ctx.args) }
166
+ end
167
+
103
168
  def step_normalize_args(ctx)
104
169
  ctx.args = step(:normalize_args) { normalize_args(ctx.aggregate, ctx.command, ctx.args) }
105
170
  end
@@ -113,7 +178,8 @@ module Hecks
113
178
  end
114
179
 
115
180
  def step_hydrate_parent(ctx)
116
- ctx.repository = @registry.repository(ctx.domain, ctx.aggregate)
181
+ # `ctx.repository` is resolved once, in `#call`, before the
182
+ # isolation decision — not here any more.
117
183
  ctx.instance = step(:hydrate_parent) {
118
184
  parent(ctx.repository, ctx.aggregate, ctx.entity_name, ctx.command_name, ctx.args, ctx.route)
119
185
  }
@@ -184,8 +250,20 @@ module Hecks
184
250
 
185
251
  step(:save) do
186
252
  @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)
253
+ # `expected_version:` — see CommandInterpreter#step_save's own
254
+ # comment: nil for a repository that isn't CAS-capable, or an
255
+ # instance never read from storage, either of which falls
256
+ # through to a plain save inside `AppendOnly#save`.
257
+ ctx.persistence_outcome = ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
258
+ if ctx.persistence_outcome.status == :stale
259
+ # NOT a `RefusalWording.render` call — see
260
+ # `CommandInterpreter#step_save`'s identical branch and
261
+ # `Runtime::StaleWrite`'s own comment.
262
+ raise(StaleWrite,
263
+ "#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
264
+ "(#{Identity.reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
265
+ "another write committed against this record after it was read")
266
+ end
189
267
  end
190
268
  end
191
269
 
@@ -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`,