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,365 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "time"
4
+ require_relative "event"
5
+ require_relative "../naming"
6
+
7
+ module Hecks
8
+ module Runtime
9
+ # THE TRANSACTIONAL OUTBOX — the durable hand-off between "a command
10
+ # committed" and "everything that was owed because it committed":
11
+ # the policies that react to its events, the process managers that
12
+ # advance on them, and (through a policy whose trigger is an
13
+ # outbound port operation) the external effects those reactions
14
+ # cause. `future-features.md` item 8, built.
15
+ #
16
+ # THE SHAPE. One row per (event, consumer). A consumer is a named
17
+ # policy or process manager that would react to the event — resolved
18
+ # at ENQUEUE time from the registry (`Fanout`), so the outbox records
19
+ # WHO was owed what, not just that an event happened. Rows move
20
+ # `pending → claimed → delivered | failed`:
21
+ #
22
+ # pending written in the SAME adapter transaction as the
23
+ # aggregate save (`Interpreting#run_dispatch_order`
24
+ # wraps `save` + `emit` + enqueue in `repository.
25
+ # transaction`), so a row exists iff the state change
26
+ # it reacts to committed — never one without the other.
27
+ # claimed the relay is about to run this consumer. Set BEFORE
28
+ # the reaction (a policy's `reenter`, a saga leg's
29
+ # dispatch, an adapter call) runs.
30
+ # delivered the consumer ran to completion — including the
31
+ # "ran and was refused" case, which is a delivered
32
+ # outcome the reaction log already records; the outbox
33
+ # tracks delivery, not the domain's answer.
34
+ # failed the consumer raised a defect (non-refusal error).
35
+ #
36
+ # DELIVERY IS INLINE BY DEFAULT — the dispatcher drains the rows it
37
+ # just wrote, in the same call, in the order C10.2 fixes (per event
38
+ # in `emits` order: that event's policy rows, then its saga rows —
39
+ # the emitting domain's own policies before other domains'). Nothing about the
40
+ # happy path is deferred or asynchronous; a caller still sees every
41
+ # reaction settled when `dispatch` returns. What changes is the
42
+ # crash window: a process that dies between commit and reaction
43
+ # used to lose the reaction silently. Now the row survives, and
44
+ # `Relay#redrive!` — run at boot by `Loader.run_boot_gates!` —
45
+ # finds it.
46
+ #
47
+ # WHAT REDRIVE DOES, AND DELIBERATELY DOESN'T. A `pending` row is
48
+ # REDRIVEN: its consumer provably never started (claiming is the
49
+ # first thing delivery does), so running it now is exactly-once by
50
+ # construction. A `claimed` row is NOT auto-redriven: the consumer
51
+ # started and the crash hid its outcome — the same reasoning
52
+ # `saga_pending_dispatch.rb` gives (a stalled transfer is a better
53
+ # defect than a double-credited one). It is surfaced loudly
54
+ # (`warn`, and a `stalled: true` entry in `Relay#log`) and left for
55
+ # `Relay#redrive!(claimed: true)` — an explicit operator decision,
56
+ # never a boot-time default. `delivery_id` (event uid + consumer) is
57
+ # UNIQUE per store, so a re-enqueue of the same fact to the same
58
+ # consumer is a no-op rather than a second row.
59
+ #
60
+ # WHICH ADAPTERS. Memory (in-process rows — visible to specs,
61
+ # gone with the process, exactly like everything else Memory holds),
62
+ # Sqlite and Postgres (a `hecks_outbox` table in the aggregate's own
63
+ # database — the only way the enqueue can share the save's
64
+ # transaction). An adapter without the contract (`outbox_enqueue`/
65
+ # `outbox_claim`/`outbox_settle`/`outbox_rows`) gets today's
66
+ # behaviour unchanged — reactions run directly, nothing durable —
67
+ # and `Registry::Verification#warn_undurable_outbox!` says so at
68
+ # boot when the domain declares anything that would have needed it.
69
+ module Outbox
70
+ STATUSES = %w[pending claimed delivered failed].freeze
71
+
72
+ Row = Struct.new(:id, :delivery_id, :event_uid, :aggregate, :domain, :kind, :consumer, :event,
73
+ :status, :attempts, :error, keyword_init: true) do
74
+ def pending? = status == "pending"
75
+ def claimed? = status == "claimed"
76
+ def delivered? = status == "delivered"
77
+ def failed? = status == "failed"
78
+
79
+ # Wire-shaped — what an adapter persists. `event` is the event's
80
+ # own `to_h` plus correlation; `Row.event_from` reverses it.
81
+ def to_h
82
+ { id: id, delivery_id: delivery_id, event_uid: event_uid, aggregate: aggregate, domain: domain,
83
+ kind: kind, consumer: consumer, event: event, status: status, attempts: attempts, error: error }
84
+ end
85
+
86
+ def to_s = "#{consumer} ← #{event[:name]}(#{event[:aggregate]}##{event[:id]}) [#{status}]"
87
+ def inspect = "#<Outbox::Row #{self}>"
88
+ end
89
+
90
+ module_function
91
+
92
+ def serialize_event(event)
93
+ event.to_h.merge(correlation: event.correlation)
94
+ end
95
+
96
+ # THE EMITTING DOMAIN'S OWN BLUEBOOK FIRST, then the rest in load
97
+ # order (C10.2) — the one policy ordering both `PolicyInterpreter#
98
+ # policies_for` and `Fanout.policies` read.
99
+ def bluebooks_home_first(registry, domain)
100
+ home, others = registry.bluebooks.each_value.partition { |bluebook| bluebook.name == domain }
101
+ home + others
102
+ end
103
+
104
+ def event_from(hash)
105
+ hash = hash.transform_keys(&:to_sym)
106
+ Event.new(
107
+ name: hash[:name],
108
+ aggregate: hash[:aggregate],
109
+ id: hash[:id],
110
+ payload: deep_symbolize(hash[:payload] || {}),
111
+ occurred_at: hash[:occurred_at],
112
+ correlation: hash[:correlation]
113
+ ).emit!
114
+ end
115
+
116
+ def deep_symbolize(value)
117
+ case value
118
+ when Hash then value.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
119
+ when Array then value.map { |element| deep_symbolize(element) }
120
+ else value
121
+ end
122
+ end
123
+
124
+ # WHO IS OWED WHAT — the same selection `PolicyInterpreter#policies_for`
125
+ # and `SagaInterpreter#advance` make at delivery time, made once at
126
+ # enqueue time so the row names its consumer. Policy rows first,
127
+ # then saga rows, event order preserved within each: exactly the
128
+ # order `Dispatcher#dispatch` always ran them in.
129
+ module Fanout
130
+ module_function
131
+
132
+ # ONE UID PER EVENT for this enqueue — the Event struct is
133
+ # frozen after `emit!`, so the uid lives on the rows rather than
134
+ # on it (and stays off `Event#to_h`, whose shape the golden and
135
+ # parity specs pin). Policy and saga rows for the same event
136
+ # share it, which is what makes `delivery_id` mean "this fact,
137
+ # this consumer".
138
+ # ROW ORDER IS DELIVERY ORDER (C10.2): per event, in `emits`
139
+ # order — that event's policy rows, then its saga rows.
140
+ def rows_for(registry, events, domain)
141
+ uids = events.to_h { |event| [event, SecureRandom.uuid] }
142
+ events.flat_map do |event|
143
+ policies(registry, event, domain, uids[event]) + sagas(registry, event, domain, uids[event])
144
+ end
145
+ end
146
+
147
+ def policies(registry, event, domain, uid)
148
+ emitting = Naming.demodulise(event.aggregate)
149
+ Outbox.bluebooks_home_first(registry, domain).flat_map do |bluebook|
150
+ bluebook.policies.filter_map do |policy|
151
+ next unless policy.event_name == event.name
152
+ next unless policy.event_qualifier.nil? || policy.event_qualifier == emitting
153
+
154
+ consumer = "policy:#{bluebook.name}::#{policy.name}"
155
+ Row.new(delivery_id: "#{uid}/#{consumer}", event_uid: uid, domain: domain,
156
+ kind: kind_for(registry, policy, bluebook.name), consumer: consumer,
157
+ event: Outbox.serialize_event(event), status: "pending", attempts: 0)
158
+ end
159
+ end
160
+ end
161
+
162
+ def sagas(registry, event, domain, uid)
163
+ bluebook = registry.bluebook(domain)
164
+ return [] unless bluebook
165
+
166
+ bluebook.process_managers.select { |process_manager| listens?(process_manager, event) }.map do |process_manager|
167
+ consumer = "saga:#{bluebook.name}::#{process_manager.name}"
168
+ Row.new(delivery_id: "#{uid}/#{consumer}", event_uid: uid, domain: domain, kind: "reaction",
169
+ consumer: consumer, event: Outbox.serialize_event(event), status: "pending", attempts: 0)
170
+ end
171
+ end
172
+
173
+ def listens?(process_manager, event)
174
+ process_manager.starts_on == event.name || process_manager.ends_on == event.name ||
175
+ !process_manager.handler_for(event.name).nil?
176
+ end
177
+
178
+ # An "effect" is a reaction whose trigger is an outbound port
179
+ # operation — the row is the durable record that an external
180
+ # call was owed, claimed right before the adapter is asked and
181
+ # settled right after. Everything else is a plain "reaction".
182
+ def kind_for(registry, policy, home_domain)
183
+ target = "#{policy.target_domain || home_domain}::#{policy.trigger_command}"
184
+ parsed = Naming.split_verb(target)
185
+ return "reaction" unless parsed
186
+
187
+ target_domain, aggregate_name, path = parsed
188
+ head, rest = path.to_s.split(".", 2)
189
+ return "reaction" unless rest
190
+
191
+ aggregate = registry.bluebook(target_domain)&.aggregate(aggregate_name)
192
+ port = aggregate&.port(head)
193
+ port&.operation(rest)&.outbound? ? "effect" : "reaction"
194
+ end
195
+ end
196
+
197
+ # THE RELAY — one per `Dispatcher`. Enqueues into a repository's
198
+ # store, drains rows inline, and redrives what a previous process
199
+ # left behind.
200
+ class Relay
201
+ attr_reader :registry, :log
202
+
203
+ # NOT `saga_log`/`reaction_log` — those are ported byte-for-byte
204
+ # by the Rust kernel (`spec/rust_conformance_spec.rb`); this is
205
+ # an additive, Ruby-only log, the same rule `saga_dispatch_log`
206
+ # and `policy_dispatch_log` already follow.
207
+ def initialize(registry)
208
+ @registry = registry
209
+ @log = []
210
+ end
211
+
212
+ # A Dispatcher hands over the interpreters a consumer runs
213
+ # through (`Dispatcher#initialize`). Until then this relay can
214
+ # enqueue (that needs only the registry) but not deliver — and
215
+ # nothing can dispatch without a dispatcher, so nothing asks it
216
+ # to. The registry holds ONE relay for its lifetime; a second
217
+ # dispatcher fronting the same registry re-attaches, which is
218
+ # fine because both dispatchers share every log and store.
219
+ def attach(policies:, sagas:)
220
+ @policies = policies
221
+ @sagas = sagas
222
+ self
223
+ end
224
+
225
+ def attached? = !@policies.nil?
226
+
227
+ # Called INSIDE the save transaction by `Interpreting` for the
228
+ # command/entity paths, and outside one by `Dispatcher` for port
229
+ # operations (which save nothing, so there is no transaction to
230
+ # share). Returns the rows as stored (ids assigned), or nil when
231
+ # the repository has no outbox — the dispatcher then reacts
232
+ # directly, exactly as before.
233
+ def enqueue(repository, events, domain)
234
+ return nil unless repository.outbox?
235
+ return [] if events.empty?
236
+
237
+ rows = Fanout.rows_for(@registry, events, domain)
238
+ rows.each { |row| row.aggregate = repository.aggregate.storage_name }
239
+ repository.outbox_enqueue(rows)
240
+ end
241
+
242
+ # Drain the rows a dispatch just committed. `rows` nil means "no
243
+ # outbox here" — react directly, the pre-outbox path.
244
+ def deliver(rows, events, domain, repository)
245
+ if rows.nil?
246
+ # PER EVENT, in `emits` order — its policies, then its sagas
247
+ # (C10.2, docs/semantics/bluebook-semantics.md); the same
248
+ # order `Fanout.rows_for` lays the outbox rows in.
249
+ events.each do |event|
250
+ @policies.react(event, domain)
251
+ @sagas.advance(event, domain)
252
+ end
253
+ return
254
+ end
255
+
256
+ rows.each { |row| deliver_row(row, repository) }
257
+ end
258
+
259
+ # One row: claim, run its consumer, settle. A claim that fails
260
+ # means another relay (or this one, re-entrantly) already has it.
261
+ def deliver_row(row, repository)
262
+ return false unless repository.outbox_claim(row.id)
263
+
264
+ row.status = "claimed"
265
+ begin
266
+ run_consumer(row)
267
+ repository.outbox_settle(row.id, status: "delivered")
268
+ row.status = "delivered"
269
+ true
270
+ rescue StandardError => e
271
+ # A DOMAIN_REFUSAL never reaches here — PolicyInterpreter and
272
+ # SagaInterpreter both rescue it as a recorded, undelivered
273
+ # reaction. Anything that does reach here is a defect in the
274
+ # relay's own path (a consumer that no longer exists, an
275
+ # adapter that raised outside the interpreters' own rescue).
276
+ repository.outbox_settle(row.id, status: "failed", error: "#{e.class}: #{e.message}")
277
+ row.status = "failed"
278
+ row.error = "#{e.class}: #{e.message}"
279
+ @log << { outbox: row.delivery_id, consumer: row.consumer, delivered: false, defect: true,
280
+ reason: row.error }
281
+ false
282
+ end
283
+ end
284
+
285
+ # Every row in every bound store, newest last. `status:` narrows.
286
+ def rows(status: nil)
287
+ stores.flat_map { |repository| repository.outbox_rows(status: status) }
288
+ end
289
+
290
+ # BOOT-TIME RECONCILIATION. Redrives `pending` rows (never
291
+ # claimed — safe by construction); surfaces `claimed` rows and
292
+ # redrives them ONLY when told to (`claimed: true`).
293
+ def redrive!(claimed: false)
294
+ redriven = []
295
+ stores.each do |repository|
296
+ repository.outbox_rows(status: "pending").each do |row|
297
+ redriven << row if deliver_row(row, repository)
298
+ end
299
+ repository.outbox_rows(status: "claimed").each do |row|
300
+ if claimed
301
+ repository.outbox_settle(row.id, status: "pending")
302
+ row.status = "pending"
303
+ redriven << row if deliver_row(row, repository)
304
+ else
305
+ warn_stalled(row)
306
+ end
307
+ end
308
+ end
309
+ redriven
310
+ end
311
+
312
+ private
313
+
314
+ def run_consumer(row)
315
+ raise WiringError, "outbox relay has no dispatcher attached — nothing can run #{row.consumer}" unless attached?
316
+
317
+ event = Outbox.event_from(row.event)
318
+ kind, fqn = row.consumer.split(":", 2)
319
+ home, name = fqn.split("::", 2)
320
+ case kind
321
+ when "policy"
322
+ policy = @registry.bluebook(home)&.policies&.find { |candidate| candidate.name == name } ||
323
+ raise(WiringError, "outbox row #{row.delivery_id} names policy #{fqn}, which no bluebook declares")
324
+ @policies.react(event, row.domain, only: [policy, home])
325
+ when "saga"
326
+ process_manager = @registry.bluebook(home)&.process_managers&.find { |candidate| candidate.name == name } ||
327
+ raise(WiringError,
328
+ "outbox row #{row.delivery_id} names process_manager #{fqn}, which no bluebook declares")
329
+ @sagas.advance(event, row.domain, only: process_manager)
330
+ else
331
+ raise WiringError, "outbox row #{row.delivery_id} has an unknown consumer kind #{kind.inspect}"
332
+ end
333
+ end
334
+
335
+ def warn_stalled(row)
336
+ warn "[hecks] outbox row #{row.delivery_id} (#{row.consumer} on #{row.event[:name]} for " \
337
+ "#{row.event[:aggregate]}##{row.event[:id]}) was claimed before the last crash/restart and never " \
338
+ "settled — its #{row.kind} may or may not have actually run. hecks does not auto-redrive a claimed " \
339
+ "row (the outcome is unknown, and redelivering it could double the effect); inspect it and " \
340
+ "redrive by hand with `runtime.outbox.redrive!(claimed: true)` once you know it is safe."
341
+ @log << { outbox: row.delivery_id, consumer: row.consumer, kind: row.kind, stalled: true,
342
+ event: row.event[:name], aggregate: row.event[:aggregate], id: row.event[:id] }
343
+ end
344
+
345
+ # Every repository the registry can resolve, one per (domain,
346
+ # aggregate), keeping only those with an outbox. Bluebooks, not
347
+ # hecksagons: a domain with no hecksagon at all is bound to the
348
+ # default adapter (Memory), which has one. An aggregate a
349
+ # hecksagon deliberately left unbound raises WiringError from
350
+ # `repository` and is skipped — the same "forgotten decision"
351
+ # rule dispatch itself applies, not softened here.
352
+ def stores
353
+ @registry.bluebooks.each_value.flat_map do |bluebook|
354
+ bluebook.aggregates.filter_map do |aggregate|
355
+ repository = @registry.repository(bluebook.name, aggregate)
356
+ repository if repository.outbox?
357
+ rescue WiringError
358
+ nil
359
+ end
360
+ end
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
@@ -8,6 +8,11 @@ require_relative "../bluebook/expression/evaluator"
8
8
 
9
9
  module Hecks
10
10
  module Runtime
11
+ # Fires the declared `policy` reactions triggered by one just-emitted
12
+ # event: scans every loaded bluebook (a policy commonly lives in a
13
+ # different domain than the event it reacts to), checks each
14
+ # candidate's `where` guard, and re-enters the dispatcher for each
15
+ # delivery — recording every outcome on the reaction log.
11
16
  class PolicyInterpreter
12
17
  attr_reader :registry
13
18
 
@@ -23,8 +28,13 @@ module Hecks
23
28
  # (one record per matched row) rather than one record ; `Array(...)`
24
29
  # is wrong here (it would explode a plain record Hash into its own
25
30
  # key/value pairs), so the two shapes are told apart explicitly.
26
- def react(event, domain)
27
- policies_for(event).each do |policy, home_domain|
31
+ # `only:` — ONE `[policy, home_domain]` pair, the outbox relay's
32
+ # way of running exactly the consumer a row names (`Runtime::
33
+ # Outbox::Relay#run_consumer`) instead of every policy that
34
+ # matches the event. Selection is otherwise identical.
35
+ def react(event, domain, only: nil)
36
+ selected = only ? [only] : policies_for(event, domain)
37
+ selected.each do |policy, home_domain|
28
38
  result = deliver(policy, event, home_domain)
29
39
  next if result.nil?
30
40
 
@@ -52,10 +62,15 @@ module Hecks
52
62
  # OWN domain (not the emitting one) when a trigger or for_each route
53
63
  # is bare, and that fallback has to travel with each match now that
54
64
  # a single event can surface policies from several different homes.
55
- def policies_for(event)
65
+ #
66
+ # THE EMITTING DOMAIN'S OWN POLICIES FIRST, in declaration order,
67
+ # then other bluebooks' in load order (C10.2) — the same order the
68
+ # outbox lays its rows in (`Outbox::Fanout.policies`) and the Rust
69
+ # kernel runs (`react_policies`: its own table, then cross-domain).
70
+ def policies_for(event, domain)
56
71
  emitting = Naming.demodulise(event.aggregate)
57
72
 
58
- @registry.bluebooks.each_value.flat_map do |bluebook|
73
+ Outbox.bluebooks_home_first(@registry, domain).flat_map do |bluebook|
59
74
  matching = bluebook.policies.select do |policy|
60
75
  policy.event_name == event.name &&
61
76
  (policy.event_qualifier.nil? || policy.event_qualifier == emitting)
@@ -78,7 +93,7 @@ module Hecks
78
93
  def where_holds?(policy, event)
79
94
  return true if policy.where.to_s.empty?
80
95
 
81
- Bluebook::Expression::Evaluator.call(policy.where, {}, event.payload.transform_keys(&:to_sym))
96
+ Bluebook::Expression::Evaluator.call_rule(policy.where_rule, {}, event.payload.transform_keys(&:to_sym))
82
97
  end
83
98
 
84
99
  def deliver(policy, event, domain)
@@ -101,11 +116,11 @@ module Hecks
101
116
  args = trigger_args(policy, event)
102
117
  @door.reenter(target, **reaction_invocation(target, args, policy, event))
103
118
  record.merge(delivered: true)
104
- rescue *DOMAIN_REFUSALS => error
119
+ rescue *DOMAIN_REFUSALS => e
105
120
  # The target refused — a fact about the domain, recorded and not
106
121
  # fatal to the command that emitted the event.
107
- record.merge(delivered: false, reason: error.message)
108
- rescue StandardError => error
122
+ record.merge(delivered: false, reason: e.message)
123
+ rescue StandardError => e
109
124
  # A DEFECT, not a refusal — a NoMethodError in an interpreter, a
110
125
  # NameError from a missing constant, a TypeError from a bad
111
126
  # assumption : exactly the class of thing DOMAIN_REFUSALS
@@ -133,8 +148,8 @@ module Hecks
133
148
  # is never silent, and left exactly where it happened for a human
134
149
  # to find — never re-raised, and never swallowed either.
135
150
  warn "[hecks] defect in reaction — policy #{policy.name} on #{event.name} " \
136
- "firing #{target}: #{error.class}: #{error.message}"
137
- record.merge(delivered: false, reason: error.message, defect: true, error_class: error.class.name)
151
+ "firing #{target}: #{e.class}: #{e.message}"
152
+ record.merge(delivered: false, reason: e.message, defect: true, error_class: e.class.name)
138
153
  end
139
154
 
140
155
  # THE FAN-OUT — `policy.for_each` names a query ; this runs it
@@ -169,12 +184,12 @@ module Hecks
169
184
  Array(rows).map do |row|
170
185
  deliver_for_each_row(target, record, trigger_args(policy, event, reference_key => row[:id]), row, policy, event)
171
186
  end
172
- rescue *DOMAIN_REFUSALS => error
173
- record.merge(delivered: false, reason: error.message)
174
- rescue StandardError => error
187
+ rescue *DOMAIN_REFUSALS => e
188
+ record.merge(delivered: false, reason: e.message)
189
+ rescue StandardError => e
175
190
  warn "[hecks] defect in reaction — policy #{policy.name} on #{event.name} " \
176
- "resolving for_each #{policy.for_each}: #{error.class}: #{error.message}"
177
- record.merge(delivered: false, reason: error.message, defect: true, error_class: error.class.name)
191
+ "resolving for_each #{policy.for_each}: #{e.class}: #{e.message}"
192
+ record.merge(delivered: false, reason: e.message, defect: true, error_class: e.class.name)
178
193
  end
179
194
 
180
195
  # THE EVENT'S OWN IDENTITY IS A FACT TOO, not only its payload. A
@@ -332,8 +347,8 @@ module Hecks
332
347
  # result, or a projection could never name the row it acts on.
333
348
  @door.reenter(target, **reaction_invocation(target, args, policy, event))
334
349
  row_record.merge(delivered: true)
335
- rescue *DOMAIN_REFUSALS => error
336
- row_record.merge(delivered: false, reason: error.message)
350
+ rescue *DOMAIN_REFUSALS => e
351
+ row_record.merge(delivered: false, reason: e.message)
337
352
  end
338
353
 
339
354
  def reaction_invocation(target, args, policy, event)
@@ -11,6 +11,14 @@ require_relative "value"
11
11
 
12
12
  module Hecks
13
13
  module Runtime
14
+ # Answers one declared aggregate or entity query against a
15
+ # repository: prefers a native adapter hook (Ports::Query.execute)
16
+ # when the store can answer directly, falling back to interpreting
17
+ # wheres/order_by/limit/offset over every loaded record itself.
18
+ # #reference_call/#reference_interpret are a SEPARATE, deliberately
19
+ # naive re-implementation of the same evaluation, used only as the
20
+ # fuzzer's oracle to catch divergence between adapters and this
21
+ # interpreter's own native path.
14
22
  class QueryInterpreter
15
23
  attr_reader :registry
16
24
 
@@ -114,11 +122,11 @@ module Hecks
114
122
  # has no concept of a reference at all — it would just read the
115
123
  # raw id straight off the record and compare THAT).
116
124
  def reference_interpret(records, declared, args, domain:, shape:)
117
- matched = records.select { |r|
118
- declared.wheres.all? { |w|
125
+ matched = records.select do |r|
126
+ declared.wheres.all? do |w|
119
127
  reference_where_holds?(w, r, args, domain: domain, shape: shape)
120
- }
121
- }
128
+ end
129
+ end
122
130
  ordered = ordered(matched, declared.order_by, declared.null_semantics)
123
131
  # OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
124
132
  # #interpret's own rows above.
@@ -157,16 +165,8 @@ module Hecks
157
165
 
158
166
  def entity_rows(domain, aggregate, dotted, args)
159
167
  entity_name, query_name = Naming.split_dotted(dotted)
160
- entity = aggregate.entities.find { |piece| piece.hecks_name == entity_name } ||
161
- raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_unknown",
162
- aggregate: aggregate.hecks_name, entity: entity_name.inspect))
163
- declared = entity.query(query_name) ||
164
- raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_query_missing",
165
- entity: entity_name, query: query_name.inspect))
168
+ entity, declared, list_attr = resolve_entity_query(aggregate, entity_name, query_name)
166
169
  declared = TenantScope.apply(declared, args)
167
- list_attr = aggregate.attributes.find { |a| a.list? && a.type.to_s == entity_name } ||
168
- raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_holds_no_list",
169
- aggregate: aggregate.hecks_name, entity: entity_name))
170
170
 
171
171
  parent_key = Naming.reference_key(aggregate.hecks_name)
172
172
  rows = @registry.repository(domain, aggregate).all.flat_map do |record|
@@ -186,6 +186,25 @@ module Hecks
186
186
  declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
187
187
  end
188
188
 
189
+ # THE THREE DECLARATIONS `entity_rows` NEEDS BEFORE IT CAN READ A
190
+ # SINGLE RECORD — the entity itself, its declared query, and the
191
+ # list attribute that holds it on the aggregate. Extracted from
192
+ # `entity_rows` (pure extraction, same lookups, same order, same
193
+ # UnknownVerb refusals) purely to separate "which declarations does
194
+ # this dotted name resolve to" from the row-computation that follows.
195
+ def resolve_entity_query(aggregate, entity_name, query_name)
196
+ entity = aggregate.entities.find { |piece| piece.hecks_name == entity_name } ||
197
+ raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_unknown",
198
+ aggregate: aggregate.hecks_name, entity: entity_name.inspect))
199
+ declared = entity.query(query_name) ||
200
+ raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_query_missing",
201
+ entity: entity_name, query: query_name.inspect))
202
+ list_attr = aggregate.attributes.find { |a| a.list? && a.type.to_s == entity_name } ||
203
+ raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_holds_no_list",
204
+ aggregate: aggregate.hecks_name, entity: entity_name))
205
+ [entity, declared, list_attr]
206
+ end
207
+
189
208
  # FieldPath.dig, not a raw `element[clause.field.to_sym]` — an
190
209
  # entity sub-list row is a plain hash merged from stored state
191
210
  # (mixed string/symbol keys depending on adapter, per `#cell`'s own
@@ -259,7 +278,8 @@ module Hecks
259
278
  declared.attributes.each_with_object(args.dup) do |attribute, normalized|
260
279
  next unless normalized.key?(attribute.name)
261
280
 
262
- normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name])
281
+ normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name],
282
+ boundary: false)
263
283
  end
264
284
  end
265
285
 
@@ -277,9 +297,9 @@ module Hecks
277
297
  def ordered(records, order_by, null_semantics = nil)
278
298
  field = order_by&.field
279
299
  Ports::Query::Ordering.apply(records, order_by, null_semantics,
280
- identity: ->(record) { record.id.to_s }) { |record|
300
+ identity: ->(record) { record.id.to_s }) do |record|
281
301
  comparable(QuerySpecification::FieldPath.dig(record, field))
282
- }
302
+ end
283
303
  end
284
304
  end
285
305
  end
@@ -44,35 +44,48 @@ module Hecks
44
44
  end
45
45
 
46
46
  with_spec.to_h do |key, source|
47
- value = if !source.is_a?(Symbol)
48
- source
49
- elsif normalized_bindings.key?(source)
50
- normalized_bindings.fetch(source)
51
- else
52
- visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
53
- unless visible
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("; ")
63
- raise UnknownArgument,
64
- "#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
65
- end
66
- visible.facts.fetch(source)
67
- end
68
-
47
+ value = resolved_mapping_value(source, normalized_bindings, normalized_scopes, label)
69
48
  [key.to_sym, Value.materialize(value)]
70
49
  end
71
50
  end
72
51
 
52
+ # ONE `with:` SOURCE, RESOLVED — pulled out of resolve_mapping
53
+ # because it is a pure function of its own arguments (a literal, a
54
+ # binding, or a name visible in some scope), with no dependency on
55
+ # anything else resolve_mapping's own to_h block is doing.
56
+ def resolved_mapping_value(source, bindings, scopes, label)
57
+ return source unless source.is_a?(Symbol)
58
+ return bindings.fetch(source) if bindings.key?(source)
59
+
60
+ visible = scopes.find { |scope| scope.facts.key?(source) }
61
+ unless visible
62
+ names = scopes.map(&:name).join(" then ")
63
+ # WHAT IS VISIBLE, NAMED. A refusal that only says which name is
64
+ # missing sent a modeler guessing field after field
65
+ # (`number`, `reference`…) at a fan-out row that is addressed
66
+ # by ONE key — `account`, the lowercase aggregate — which
67
+ # nothing else in the domain spells out. The names each scope
68
+ # actually offers are the whole diagnosis; the refusal now
69
+ # lists them, scope by scope.
70
+ offered = scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
71
+ raise UnknownArgument,
72
+ "#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
73
+ end
74
+ visible.facts.fetch(source)
75
+ end
76
+ private_class_method :resolved_mapping_value
77
+
73
78
  # A reaction without an explicit projection retains wholesale legacy
74
79
  # forwarding. Choosing `with:` opts into the strict envelope: only target
75
80
  # command attributes enter `with:`, while identities become `to:`.
81
+ # `consumed` accumulates across the aggregate-identity and entity-
82
+ # identity steps below, and refuse_unconsumed! at the end reads the
83
+ # FINAL list — an ordering dependency threaded through one shared
84
+ # local. Already leans on private helpers (identity_for,
85
+ # source_receiver_for, command_facts, refuse_unconsumed!) for every
86
+ # piece that IS self-contained; what remains is the sequencing
87
+ # itself, which further splitting would only relocate, not remove.
88
+ # rubocop:disable-next Metrics/MethodLength
76
89
  def build(registry:, verb:, projected:, explicit:, passthrough: [], source_receiver: nil)
77
90
  args = projected.transform_keys(&:to_sym)
78
91
  unless explicit