hecks 1.0.2 → 1.2.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 (247) 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 +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
@@ -8,6 +8,12 @@ require_relative "registry"
8
8
 
9
9
  module Hecks
10
10
  module Runtime
11
+ # The boot entry point: loads a bluebook directory (or an explicit
12
+ # file list) into a fresh Registry, runs every registered boot gate
13
+ # (era checks, saga rehydration, …), and hands back the Dispatcher
14
+ # (or RemoteDispatcher, for a domain declaring `dispatched_by
15
+ # ("Lambda")`) bound to it — installing the `Widget::Item.Add(...)`
16
+ # facade sugar unless the caller opts out.
11
17
  class Loader
12
18
  # `install_facade:` defaults on — every ordinary caller wants
13
19
  # `Widget::Item.Add(...)` sugar. A caller that only ever dispatches
@@ -45,9 +51,24 @@ module Hecks
45
51
 
46
52
  run_boot_gates!(registry, directory)
47
53
  dispatcher = dispatcher_for(registry)
54
+ redrive_outbox!(dispatcher)
48
55
  install_facade ? bind_runtime(dispatcher) : dispatcher
49
56
  end
50
57
 
58
+ # THE OUTBOX'S BOOT-TIME RECONCILIATION — after the dispatcher
59
+ # exists (a row's consumer runs through its interpreters, so this
60
+ # cannot be a plain registry gate the way saga rehydration is) and
61
+ # after saga rehydration (a redriven row may advance a saga, which
62
+ # must already be in memory). `pending` rows are redriven —
63
+ # provably never started; `claimed` rows are surfaced, never
64
+ # auto-redriven — see `Runtime::Outbox`. A remote dispatcher has
65
+ # no local stores to scan.
66
+ def self.redrive_outbox!(dispatcher)
67
+ return unless dispatcher.respond_to?(:outbox)
68
+
69
+ dispatcher.outbox.redrive!
70
+ end
71
+
51
72
  # THE EXPLICIT-FILE FORM — `paths` names the exact bluebook/hecksagon/
52
73
  # world files to boot, in place, wherever they actually live. `boot`
53
74
  # above only ever takes a directory and globs it; that is the right
@@ -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