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
@@ -1,8 +1,21 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- WhereClause = Struct.new(:field, :op, :value, keyword_init: true) do
5
- def to_h = { field: field.to_s, op: op.to_s, value: QuerySpecification.render_value(value) }
4
+ # `target` which many-side `include`d aggregate this clause applies
5
+ # to on a `read_model` with more than one (ADR 0055's own `on:`,
6
+ # `ReadModelBuilder#where_impl`). Always `nil` for a plain `Query`'s
7
+ # own `where` (that builder never overrides `where_impl` to accept
8
+ # `on:`), and `nil` for a `read_model` with a single many-side head,
9
+ # where naming one is unnecessary. `to_h` omits the key entirely
10
+ # rather than emitting `target: nil` — the same "absent, not null"
11
+ # convention `count`/`median_field` already established
12
+ # (`lib/hecks/bluebook/read_model.rb`), so every read model that
13
+ # never uses `on:` keeps its existing wire shape byte-identical.
14
+ WhereClause = Struct.new(:field, :op, :value, :target, keyword_init: true) do
15
+ def to_h
16
+ base = { field: field.to_s, op: op.to_s, value: QuerySpecification.render_value(value) }
17
+ target ? base.merge(target: target.to_s) : base
18
+ end
6
19
  end
7
20
  end
8
21
  end
@@ -85,8 +85,8 @@ module Hecks
85
85
  # or a value object carrying at least one numeric member. A dotted
86
86
  # path must land on a numeric primitive itself — the convention does
87
87
  # not reach through a named path, it IS the absence of one.
88
- def numeric?(attribute, segments, &value_object)
89
- leaf = leaf_attribute(attribute, segments, &value_object)
88
+ def numeric?(attribute, segments, &)
89
+ leaf = leaf_attribute(attribute, segments, &)
90
90
  return false if leaf.nil? || leaf.list? || leaf.reference?
91
91
  return true if NUMERIC_PRIMITIVES.include?(leaf.type.to_s)
92
92
  return false unless segments.empty?
@@ -98,8 +98,8 @@ module Hecks
98
98
  # A dotted path must end on a SCALAR member — landing on a value
99
99
  # object would hand SQL a JSON object where the reference
100
100
  # interpreter unwraps a hash, and the two would answer differently.
101
- def scalar_leaf?(attribute, segments, &value_object)
102
- leaf = leaf_attribute(attribute, segments, &value_object)
101
+ def scalar_leaf?(attribute, segments, &)
102
+ leaf = leaf_attribute(attribute, segments, &)
103
103
  !leaf.nil? && !leaf.list? && !leaf.reference? && SCALAR_PRIMITIVES.include?(leaf.type.to_s)
104
104
  end
105
105
  end
@@ -3,11 +3,14 @@ require_relative "../common/options"
3
3
  module Hecks
4
4
  module QuerySpecification
5
5
  module ReadModel
6
+ # The read-model-specific specification: Common::Options' shared
7
+ # query attributes plus `joins`, the one thing a read model
8
+ # declares that a plain Query does not.
6
9
  class Specification < Common::Options
7
10
  attr_reader :joins
8
11
 
9
- def initialize(joins: [], **options)
10
- super(**options)
12
+ def initialize(joins: [], **)
13
+ super(**)
11
14
  @joins = joins
12
15
  end
13
16
 
@@ -7,12 +7,19 @@ module Hecks
7
7
  # resolution stays in Router; this object only adapts it to constants and
8
8
  # method calls.
9
9
  class NamespaceInstaller
10
+ # Returned by the `.options(version:)` method installed on each
11
+ # namespace module — forwards any command/query verb called on it
12
+ # to the router, pinned to this specific FQN version rather than
13
+ # the router's default resolution.
10
14
  class OptionsProxy
11
15
  def initialize(router:, realm:, domain:, aggregate:, options:)
12
16
  unknown = options.keys - [:version]
13
17
  raise ArgumentError, "unknown router options: #{unknown.join(', ')}" unless unknown.empty?
14
18
 
15
- @router, @realm, @domain, @aggregate = router, realm, domain, aggregate
19
+ @router = router
20
+ @realm = realm
21
+ @domain = domain
22
+ @aggregate = aggregate
16
23
  @version = options[:version]&.to_s
17
24
  end
18
25
 
@@ -103,12 +110,12 @@ module Hecks
103
110
 
104
111
  target.define_singleton_method(:find) do |id|
105
112
  found = dispatcher.registry.repository(domain, ir).find(id)
106
- found && Facade::Handle.new(dispatcher: dispatcher, domain: domain, ir: ir, instance: found)
113
+ found && Facade::Handle.new(dispatcher: dispatcher, domain: domain, aggregate: ir, instance: found)
107
114
  end
108
115
 
109
116
  target.define_singleton_method(:all) do
110
117
  dispatcher.registry.repository(domain, ir).all.map do |instance|
111
- Facade::Handle.new(dispatcher: dispatcher, domain: domain, ir: ir, instance: instance)
118
+ Facade::Handle.new(dispatcher: dispatcher, domain: domain, aggregate: ir, instance: instance)
112
119
  end
113
120
  end
114
121
  end
@@ -118,16 +125,17 @@ module Hecks
118
125
  current_entries.reject { |entry| entry.fqn.aggregate.nil? }
119
126
  .group_by { |entry| [entry.fqn.aggregate, entry.fqn.verb] }
120
127
  .each do |(aggregate, verb), candidates|
121
- shortcut_target(aggregate).define_singleton_method(verb) { |**args|
128
+ shortcut_target(aggregate).define_singleton_method(verb) do |**args|
122
129
  installer.send(:dispatch_short, candidates, **args)
123
- }
130
+ end
124
131
  end
125
132
  end
126
133
 
127
134
  def dispatch_short(candidates, **args)
128
135
  if candidates.length > 1
129
136
  shown = candidates.map { |entry| entry.fqn.to_s }.sort.join(", ")
130
- aggregate, verb = candidates.first.fqn.aggregate, candidates.first.fqn.verb
137
+ aggregate = candidates.first.fqn.aggregate
138
+ verb = candidates.first.fqn.verb
131
139
  raise AmbiguousShortRoute, "#{aggregate}.#{verb} is ambiguous — choose one of: #{shown}"
132
140
  end
133
141
 
@@ -137,8 +145,10 @@ module Hecks
137
145
 
138
146
  def shortcut_target(aggregate)
139
147
  constant = Object.const_get(aggregate, false) if Object.const_defined?(aggregate, false)
140
- raise NameError,
141
- "cannot install Bluebook shortcut #{aggregate}: it is not a module" if constant && !constant.is_a?(Module)
148
+ if constant && !constant.is_a?(Module)
149
+ raise NameError,
150
+ "cannot install Bluebook shortcut #{aggregate}: it is not a module"
151
+ end
142
152
 
143
153
  constant || Object.const_set(aggregate, Module.new)
144
154
  end
@@ -146,8 +156,10 @@ module Hecks
146
156
  def namespace_for(realm, domain, aggregate)
147
157
  [realm, domain, aggregate].compact.reduce(Object) do |parent, name|
148
158
  constant = parent.const_get(name, false) if parent.const_defined?(name, false)
149
- raise NameError,
150
- "cannot install Bluebook route under #{parent}::#{name}: it is not a module" if constant && !constant.is_a?(Module)
159
+ if constant && !constant.is_a?(Module)
160
+ raise NameError,
161
+ "cannot install Bluebook route under #{parent}::#{name}: it is not a module"
162
+ end
151
163
 
152
164
  constant || parent.const_set(name, Module.new)
153
165
  end
@@ -18,17 +18,39 @@ module Hecks
18
18
  end
19
19
  end
20
20
 
21
- def apply(instance, aggregate, mutation, args)
21
+ # A CASE STATEMENT OVER A CLOSED, DECLARED SET — every mutation op
22
+ # the grammar can emit gets its own branch, including the `else`
23
+ # backstop for the day a new op reaches this method undeclared (see
24
+ # its own comment). Splitting each branch into its own method would
25
+ # not reduce what a reader has to hold at once (each op's own
26
+ # comment already explains why IT is shaped the way it is) and
27
+ # would obscure that the set is closed and exhaustive.
28
+ # rubocop:disable Lint/DuplicateBranch -- :delegate and :corrects
29
+ # both no-op here, for two unrelated documented reasons (see each
30
+ # branch's own comment below); merging would blur that distinction.
31
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
32
+ #
33
+ # `pre` — THE PRE-DISPATCH STATE (C4.2): every source below reads
34
+ # it, every target is written to `instance`. A command's effects
35
+ # are one update set; declaration order carries no meaning, and
36
+ # build refuses a field written twice (`CommandBuilder#
37
+ # refuse_duplicate_targets!`).
38
+ def apply(instance, aggregate, mutation, args, pre = instance)
22
39
  case mutation.op
23
40
  when :set
24
- value = mutation.source.is_a?(StateRef) ? instance[mutation.source.name] : @rules.resolve_source(mutation.source, args)
41
+ value = if mutation.source.is_a?(StateRef)
42
+ pre[mutation.source.name]
43
+ else
44
+ @rules.resolve_source(mutation.source,
45
+ args)
46
+ end
25
47
  instance[mutation.target] = Value.for(aggregate, mutation.target, value)
26
48
  when :append
27
- instance[mutation.target] = appended(instance, aggregate, mutation, args)
49
+ instance[mutation.target] = appended(pre, aggregate, mutation, args)
28
50
  when :increment, :decrement
29
51
  amount = @rules.resolve_source(mutation.source, args)
30
52
  attribute = aggregate.attribute(mutation.target)
31
- current = instance[mutation.target]
53
+ current = pre[mutation.target]
32
54
  # Vendored fix, not (yet) upstream hecks (migration plan
33
55
  # task 9): see #rewrap_arithmetic_result's own comment below
34
56
  # -- `amount` is wrapped ONLY when `current` already is, not
@@ -42,7 +64,7 @@ module Hecks
42
64
  # RemoveDependency/DeactivateSprint: "a concurrent Add can
43
65
  # never be lost").
44
66
  when :remove
45
- instance[mutation.target] = removed(instance, aggregate, mutation, args)
67
+ instance[mutation.target] = removed(pre, aggregate, mutation, args)
46
68
  # Vendored addition, not (yet) upstream hecks (migration
47
69
  # plan task 4, i106): multiply/clamp, the scale/bound pair
48
70
  # alongside increment/decrement's add/subtract pair -- see
@@ -50,7 +72,7 @@ module Hecks
50
72
  when :multiply
51
73
  amount = @rules.resolve_source(mutation.source, args)
52
74
  attribute = aggregate.attribute(mutation.target)
53
- current = instance[mutation.target]
75
+ current = pre[mutation.target]
54
76
  amount = Value.for_attribute(aggregate, attribute, amount) if attribute && current.is_a?(Value)
55
77
  result = @rules.multiply(current, amount, mutation.target)
56
78
  instance[mutation.target] = rewrap_arithmetic_result(aggregate, attribute, current, result)
@@ -61,7 +83,7 @@ module Hecks
61
83
  # a no-op for an Array (it only special-cases Symbol), so it's
62
84
  # read straight.
63
85
  when :clamp
64
- instance[mutation.target] = @rules.clamp(instance[mutation.target], mutation.source, mutation.target)
86
+ instance[mutation.target] = @rules.clamp(pre[mutation.target], mutation.source, mutation.target)
65
87
  # `delegates_to` — CommandBuilder#delegates_to's own comment gives
66
88
  # the full reasoning for storing it as a mutation at all. A REAL
67
89
  # no-op here, not a gap: it targets no field on THIS instance —
@@ -94,6 +116,8 @@ module Hecks
94
116
  raise Runtime::WiringError, "no mutation applier handles :#{mutation.op} — add one before declaring it"
95
117
  end
96
118
  end
119
+ # rubocop:enable Lint/DuplicateBranch
120
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
97
121
 
98
122
  # A CALLER-SUPPLIED ARG, FIRST -- an append's own field can also
99
123
  # name something the SUBJECT ALREADY KNOWS about itself, falling
@@ -122,21 +146,23 @@ module Hecks
122
146
  fields = mutation.source.transform_values { |source| resolve_append_source(source, instance, args) }
123
147
  element_type = aggregate.attribute(mutation.target)&.type
124
148
  value_object = aggregate.value_object(element_type)
125
- if value_object
126
- value_object.attributes.each do |attribute|
127
- held = fields[attribute.name]
128
- # A SINGLE-FIELD VALUE unwraps to its scalar so it bridges
129
- # into the element's own (differently named) wrapper; a
130
- # MULTI-FIELD one (a `state(:en_passant_square)` Square
131
- # copied off the record) has no scalar to stand in for it
132
- # and is handed across whole `Value.for_attribute` keeps a
133
- # value of the element field's own type as it is.
134
- fields[attribute.name] = Value.scalar(held) if held.is_a?(Value) && held.to_h.size == 1
135
- end
149
+ value_object&.attributes&.each do |attribute|
150
+ held = fields[attribute.name]
151
+ # A SINGLE-FIELD VALUE unwraps to its scalar so it bridges
152
+ # into the element's own (differently named) wrapper; a
153
+ # MULTI-FIELD one (a `state(:en_passant_square)` Square
154
+ # copied off the record) has no scalar to stand in for it
155
+ # and is handed across whole `Value.for_attribute` keeps a
156
+ # value of the element field's own type as it is.
157
+ fields[attribute.name] = Value.scalar(held) if held.is_a?(Value) && held.to_h.size == 1
136
158
  end
137
- element = value_object ? Value.build(value_object, fields,
138
- aggregate) : entity_element(aggregate, element_type, instance[mutation.target],
139
- fields)
159
+ element = if value_object
160
+ Value.build(value_object, fields,
161
+ aggregate)
162
+ else
163
+ entity_element(aggregate, element_type, instance[mutation.target],
164
+ fields)
165
+ end
140
166
 
141
167
  # FROZEN, like every other value the domain hands back. An
142
168
  # appended list used to come back mutable, so a caller could
@@ -215,7 +241,7 @@ module Hecks
215
241
  end
216
242
  if entity.identified_by && !fields.key?(entity.identified_by)
217
243
  attribute = entity.attribute(entity.identified_by)
218
- fields[entity.identified_by] = Value.from_identifier(aggregate, attribute, Array(current).size + 1)
244
+ fields[entity.identified_by] = Value.from_identifier(aggregate, attribute, next_identity(current, entity))
219
245
  else
220
246
  check_entity_collision(aggregate, entity, current, fields)
221
247
  end
@@ -223,6 +249,15 @@ module Hecks
223
249
  fields
224
250
  end
225
251
 
252
+ # THE MINTED IDENTITY IS ONE PAST THE HIGHEST HELD (C4.5) — not
253
+ # `size + 1`, which repeats an identity the moment the list has
254
+ # ever shrunk. One strategy for the language, so the IR declares
255
+ # none; the Rust generators mint by the same rule.
256
+ def next_identity(current, entity)
257
+ held = Array(current).map { |element| Value.scalar(element[entity.identified_by]).to_i }
258
+ held.max.to_i + 1
259
+ end
260
+
226
261
  # THE SAME CHECK #hydrate GIVES EVERY CREATING AGGREGATE COMMAND
227
262
  # (`repository.find(id)`, above this file in command_interpreter.rb),
228
263
  # one level down. Reached only on the two branches #entity_element
@@ -47,8 +47,8 @@ module Hecks
47
47
  # `result` and `transition`/`old_state` default to nil until the step
48
48
  # that sets them runs, same as they were unset locals before that point.
49
49
  Context = Struct.new(:domain, :aggregate, :command, :args, :repository, :instance, :transition, :old_state,
50
- :result, :correlation, :route, :plan, :strategy, :persistence_outcome, :delegated_events,
51
- :dry_run, :correction_bindings)
50
+ :result, :correlation, :route, :plan, :strategy, :persistence_outcome, :pending_delegation,
51
+ :dry_run, :correction_bindings, :outbox_rows)
52
52
 
53
53
  def initialize(registry, rules:)
54
54
  @registry = registry
@@ -82,7 +82,7 @@ module Hecks
82
82
  ctx.repository = @registry.repository(domain, aggregate)
83
83
  lock_id = Identity.best_effort(aggregate, args, route, reference_key: reference_key(command))
84
84
  run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
85
- [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
85
+ [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome, ctx.outbox_rows]
86
86
  rescue StaleWrite
87
87
  attempt += 1
88
88
  retry if attempt < MAX_STALE_WRITE_RETRIES
@@ -116,7 +116,7 @@ module Hecks
116
116
  # `ctx.repository` is resolved once, in `#call`, before the
117
117
  # isolation decision (lock vs. CAS+retry) — not here any more.
118
118
  ctx.strategy = ctx.plan.strategy_for(capabilities: ctx.repository.capabilities)
119
- ctx.instance = step(:hydrate) {
119
+ ctx.instance = step(:hydrate) do
120
120
  if ctx.plan.complete_state? && ctx.plan.state_independent?
121
121
  hydrate_complete_state(ctx.repository, ctx.aggregate, ctx.command, ctx.args, ctx.route, ctx.strategy)
122
122
  elsif ctx.plan.complete_state?
@@ -126,11 +126,11 @@ module Hecks
126
126
  else
127
127
  hydrate_existing(ctx.repository, ctx.aggregate, ctx.command, ctx.args, ctx.route)
128
128
  end
129
- }
129
+ end
130
130
  end
131
131
 
132
132
  def step_enforce_givens(ctx)
133
- step(:enforce_givens) {
133
+ step(:enforce_givens) do
134
134
  # STRUCTURAL, before the declared givens — the same ordering
135
135
  # NotFound/AlreadyExists already get at hydration: "does the
136
136
  # fact this command's corrects names even exist" is not a
@@ -142,7 +142,7 @@ module Hecks
142
142
  ctx.correction_bindings = @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
143
143
  @rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
144
144
  declaring: ctx.aggregate, parent: ctx.instance, correction: ctx.correction_bindings)
145
- }
145
+ end
146
146
  end
147
147
 
148
148
  def step_admissible_transition(ctx)
@@ -161,11 +161,17 @@ module Hecks
161
161
  # arithmetic via Value#with, append builds a new array), never
162
162
  # edit a held value in place.
163
163
  ctx.old_state = ctx.instance.state.dup unless ctx.command.ensures.empty?
164
- step(:apply_mutations) {
165
- ctx.command.mutations.each { |mutation|
166
- apply(ctx.instance, ctx.aggregate, mutation, ctx.args)
167
- }
168
- }
164
+ step(:apply_mutations) do
165
+ # ONE UPDATE SET OVER THE PRE-DISPATCH STATE (C4.2, docs/
166
+ # semantics/bluebook-semantics.md): every effect's sources read
167
+ # `pre` — the state as it was before this command — and its
168
+ # target is written to the candidate; declaration order carries
169
+ # no meaning, and build refuses a field written twice.
170
+ pre = ctx.instance.state.dup
171
+ ctx.command.mutations.each do |mutation|
172
+ apply(ctx.instance, ctx.aggregate, mutation, ctx.args, pre)
173
+ end
174
+ end
169
175
  end
170
176
 
171
177
  def step_advance_lifecycle(ctx)
@@ -188,11 +194,21 @@ module Hecks
188
194
  # `step_locate_element`/`step_apply_mutations` mutate their OWN
189
195
  # freshly-loaded copy — the only difference is WHICH already-in-
190
196
  # memory record gets handed in.
197
+ #
198
+ # Reimplements the entity command pipeline's own order (givens,
199
+ # transition, mutations, ensures, emit) inline, deliberately — see
200
+ # the comment above on why this must run strictly between this
201
+ # command's own mutations and its ensures/invariants/save. Splitting
202
+ # it would scatter that exact ordering across method boundaries and
203
+ # force threading element/view/transition/old_element/settled
204
+ # through as parameters — the `with:` bug documented just below is
205
+ # exactly the kind of ordering/plumbing mistake that shape invites.
206
+ # rubocop:disable-next Metrics/AbcSize
191
207
  def step_delegate_to_entity(ctx)
192
208
  delegation = ctx.command.mutations.find { |mutation| mutation.op == :delegate }
193
209
  return unless delegation
194
210
 
195
- step(:delegate_to_entity) {
211
+ step(:delegate_to_entity) do
196
212
  entity_name, _dot, command_name = delegation.target.to_s.rpartition(".")
197
213
  entity = ctx.aggregate.entities.find { |e| e.hecks_name == entity_name } ||
198
214
  raise(WiringError, "#{ctx.command.hecks_name} delegates_to #{entity_name}." \
@@ -227,23 +243,32 @@ module Hecks
227
243
  transition = @rules.admissible_transition(entity, target_command, view)
228
244
 
229
245
  old_element = target_command.ensures.empty? ? nil : element.dup
230
- target_command.mutations.each { |mutation|
231
- EntityElement.apply_to_element(@rules, ctx.aggregate, entity, element, mutation, target_args)
232
- }
246
+ pre = element.dup # C4.2 the update set reads the element as it was
247
+ target_command.mutations.each do |mutation|
248
+ EntityElement.apply_to_element(@rules, ctx.aggregate, entity, element, mutation, target_args, pre)
249
+ end
233
250
  element[entity.lifecycle.field] = transition.target if transition
234
251
 
235
252
  settled = Instance.new(aggregate: entity, id: view.id, state: element)
236
253
  @rules.enforce_ensures(settled, target_command, target_args, old: old_element, domain: ctx.domain, parent: ctx.instance)
237
254
 
238
- ctx.delegated_events = @rules.emit(target_command, ctx.domain, ctx.aggregate, ctx.instance, target_args, ctx.repository)
239
- }
255
+ # NOT emitted here C7.2 (docs/semantics/bluebook-semantics.md):
256
+ # a refused command records nothing, and the parent's own
257
+ # `ensures`/`enforce_invariants`/`save` steps still run after
258
+ # this one. The target's emission is parked and performed by
259
+ # `step_emit`, after the parent committed — where every other
260
+ # command's events are emitted too. (Before this, the entity
261
+ # leg's events were on the event log and in the adapter before
262
+ # the parent could refuse.)
263
+ ctx.pending_delegation = [target_command, target_args]
264
+ end
240
265
  end
241
266
 
242
267
  def step_enforce_ensures(ctx)
243
- step(:enforce_ensures) {
268
+ step(:enforce_ensures) do
244
269
  @rules.enforce_ensures(ctx.instance, ctx.command, ctx.args, old: ctx.old_state,
245
270
  domain: ctx.domain, parent: ctx.instance, correction: ctx.correction_bindings || {})
246
- }
271
+ end
247
272
  end
248
273
 
249
274
  def step_enforce_invariants(ctx)
@@ -261,41 +286,49 @@ module Hecks
261
286
  step(:save) do
262
287
  @rules.resolve_state_references(ctx.domain, ctx.aggregate, ctx.instance.state)
263
288
  seed_projected_fields(ctx)
264
- ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
265
- # A SECOND CREATION IS NOT A FRESH ONE — see
266
- # hydrate_complete_state's own comment; the
267
- # same refusal, on the same terms, for the
268
- # complete-state path. `insert_only:` asks the
269
- # ADAPTER to decide and refuse ATOMICALLY
270
- # (never writing a `creates?` command over an
271
- # identity that already names a record) rather
272
- # than this interpreter reading the record
273
- # first to check a `repository.find` before
274
- # every atomic_put would be exactly the read
275
- # this strategy exists to skip.
276
- ctx.repository.atomic_put(ctx.instance, insert_only: ctx.command.creates?)
277
- else
278
- # `expected_version:` is `ctx.instance.version` nil for a
279
- # brand-new record (never read from storage) or when the
280
- # repository isn't CAS-capable, either of which falls straight
281
- # through to a plain, unconditional save inside `AppendOnly#save`.
282
- ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
283
- end
284
- if ctx.persistence_outcome.status == :conflicted
285
- raise(AlreadyExists, RefusalWording.render("AlreadyExists", "creating_duplicate",
286
- command: ctx.command.hecks_name, aggregate: ctx.aggregate.hecks_name,
287
- identity: identity_reading(ctx.aggregate),
288
- offered: Rendering.describe(ctx.instance.id)))
289
- elsif ctx.persistence_outcome.status == :stale
290
- # NOT a `RefusalWording.render` call — this is not a declared
291
- # vocabulary refusal, just a plain, informative message. See
292
- # `Runtime::StaleWrite`'s own comment: caught by `#call`'s
293
- # retry loop, re-raised only once retries are exhausted.
294
- raise(StaleWrite,
295
- "#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
296
- "(#{identity_reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
297
- "another write committed against this record after it was read")
298
- end
289
+ ctx.persistence_outcome = persist_instance(ctx)
290
+ raise_for_persistence_outcome!(ctx)
291
+ end
292
+ end
293
+
294
+ def persist_instance(ctx)
295
+ if ctx.strategy == DependencyPlanning::ATOMIC_PUT
296
+ # A SECOND CREATION IS NOT A FRESH ONE — see
297
+ # hydrate_complete_state's own comment; the
298
+ # same refusal, on the same terms, for the
299
+ # complete-state path. `insert_only:` asks the
300
+ # ADAPTER to decide and refuse ATOMICALLY
301
+ # (never writing a `creates?` command over an
302
+ # identity that already names a record) rather
303
+ # than this interpreter reading the record
304
+ # first to check a `repository.find` before
305
+ # every atomic_put would be exactly the read
306
+ # this strategy exists to skip.
307
+ ctx.repository.atomic_put(ctx.instance, insert_only: ctx.command.creates?)
308
+ else
309
+ # `expected_version:` is `ctx.instance.version` nil for a
310
+ # brand-new record (never read from storage) or when the
311
+ # repository isn't CAS-capable, either of which falls straight
312
+ # through to a plain, unconditional save inside `AppendOnly#save`.
313
+ ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
314
+ end
315
+ end
316
+
317
+ def raise_for_persistence_outcome!(ctx)
318
+ if ctx.persistence_outcome.status == :conflicted
319
+ raise(AlreadyExists, RefusalWording.render("AlreadyExists", "creating_duplicate",
320
+ command: ctx.command.hecks_name, aggregate: ctx.aggregate.hecks_name,
321
+ identity: identity_reading(ctx.aggregate),
322
+ offered: Rendering.describe(ctx.instance.id)))
323
+ elsif ctx.persistence_outcome.status == :stale
324
+ # NOT a `RefusalWording.render` call — this is not a declared
325
+ # vocabulary refusal, just a plain, informative message. See
326
+ # `Runtime::StaleWrite`'s own comment: caught by `#call`'s
327
+ # retry loop, re-raised only once retries are exhausted.
328
+ raise(StaleWrite,
329
+ "#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
330
+ "(#{identity_reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
331
+ "another write committed against this record after it was read")
299
332
  end
300
333
  end
301
334
 
@@ -334,9 +367,10 @@ module Hecks
334
367
 
335
368
  # A DELEGATING COMMAND EMITS NOTHING OF ITS OWN (`CommandBuilder#build`'s
336
369
  # own guard refuses declaring `emits` alongside `delegates_to`) — its
337
- # result IS whatever `step_delegate_to_entity` already collected from
338
- # the target entity command's own `emits`, not a second, empty call
339
- # into `@rules.emit` for a command with no announced events at all.
370
+ # result IS the target entity command's own `emits`, parked by
371
+ # `step_delegate_to_entity` and emitted HERE, after save (C7.2), not
372
+ # a second, empty call into `@rules.emit` for a command with no
373
+ # announced events at all.
340
374
  #
341
375
  # `dry_run:` skips this too, same reasoning as `step_save` — nothing
342
376
  # was committed, so `ctx.result` stays nil and `Dispatcher#dry_run?`
@@ -344,15 +378,20 @@ module Hecks
344
378
  def step_emit(ctx)
345
379
  return if ctx.dry_run
346
380
 
347
- ctx.result = step(:emit) {
348
- # `ctx.delegated_events` is only ever set by `step_delegate_to_entity`,
349
- # and only when this command carries a `:delegate` mutation — an
350
- # empty Array (the target genuinely emitted nothing) is still
351
- # truthy in Ruby, so this reads correctly either way.
352
- next ctx.delegated_events if ctx.delegated_events
381
+ ctx.result = step(:emit) do
382
+ # `ctx.pending_delegation` is only ever set by
383
+ # `step_delegate_to_entity`, and only when this command carries a
384
+ # `:delegate` mutation.
385
+ if ctx.pending_delegation
386
+ target_command, target_args = ctx.pending_delegation
387
+ # The same dispatch, so the same correlation — a saga-driven
388
+ # door's events used to lose their stamp here.
389
+ next @rules.emit(target_command, ctx.domain, ctx.aggregate, ctx.instance, target_args, ctx.repository,
390
+ ctx.correlation)
391
+ end
353
392
 
354
393
  @rules.emit(ctx.command, ctx.domain, ctx.aggregate, ctx.instance, ctx.args, ctx.repository, ctx.correlation)
355
- }
394
+ end
356
395
  end
357
396
 
358
397
  def hydrate_existing(repository, aggregate, command, args, route = nil)
@@ -143,9 +143,12 @@ module Hecks
143
143
  attrs = attrs.merge(parent: parent.state) if parent
144
144
  attrs = attrs.merge(correction) unless correction.empty?
145
145
  command.givens.each do |given|
146
- next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
146
+ next if Bluebook::Expression::Evaluator.call_rule(given, state, attrs)
147
147
 
148
- raise GivenNotMet, "#{command.hecks_name} refused — #{given.description}"
148
+ raise GivenNotMet.new(
149
+ "#{command.hecks_name} refused — #{given.description}",
150
+ detail: Bluebook::Expression::Evaluator.comparison_detail(given.canonical, state, attrs)
151
+ )
149
152
  end
150
153
 
151
154
  enforce_lifecycle_guard(declaring, command, subject) if declaring
@@ -177,6 +180,16 @@ module Hecks
177
180
  # one, made deliberately: `as:` reads as "the instance being
178
181
  # corrected," which is naturally the latest fact on record, not
179
182
  # an arbitrary one.
183
+ # C9.2 (docs/semantics/bluebook-semantics.md) — a correction target
184
+ # is judged against the record's DURABLE history: the events the
185
+ # aggregate's own store recorded (`AppendOnly#events`), which
186
+ # survive a restart the way the Rust kernel's persisted
187
+ # `emitted_<event>` flag does. The in-process log is the fallback
188
+ # only for an adapter that records no readable history.
189
+ def correction_history(domain, aggregate)
190
+ @registry.repository(domain, aggregate).events || @registry.event_log
191
+ end
192
+
180
193
  def enforce_correction_target(instance, aggregate, command, domain:)
181
194
  bindings = {}
182
195
  command.mutations.each do |mutation|
@@ -184,8 +197,8 @@ module Hecks
184
197
 
185
198
  event_key = "#{domain}::#{aggregate.hecks_name}"
186
199
  event_name = mutation.target.to_s
187
- corrected = @registry.event_log.reverse.find do |event|
188
- event.name == event_name && event.aggregate == event_key && event.id == instance.id
200
+ corrected = correction_history(domain, aggregate).reverse.find do |event|
201
+ event.name == event_name && event.aggregate == event_key && event.id.to_s == instance.id.to_s
189
202
  end
190
203
 
191
204
  unless corrected
@@ -257,12 +270,17 @@ module Hecks
257
270
  # one) wins right alongside it — a settled-record ensures can
258
271
  # reference the correction target exactly as freely as a
259
272
  # pre-mutation given already can.
260
- attrs = args.merge(dereference(domain, command, args))
273
+ # C2.3 (docs/semantics/bluebook-semantics.md) an ensures reads
274
+ # the SETTLED STATE first: an argument that shares a field's
275
+ # name does not shadow the candidate here (it does in a given,
276
+ # C2.2), so `sets :note` + `ensures { note == ... }` judges what
277
+ # landed, and `old.<field>` remains the pre-state.
278
+ attrs = args.reject { |name, _| subject.key?(name) }.merge(dereference(domain, command, args))
261
279
  attrs = attrs.merge(parent: parent.state) if parent
262
280
  attrs = attrs.merge(correction) unless correction.empty?
263
281
  attrs = attrs.merge(old: old)
264
282
  command.ensures.each do |rule|
265
- next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
283
+ next if Bluebook::Expression::Evaluator.call_rule(rule, state, attrs)
266
284
 
267
285
  raise EnsuresNotMet, "#{command.hecks_name} refused — #{rule.description}"
268
286
  end
@@ -291,7 +309,7 @@ module Hecks
291
309
  state = GuardState.new(subject)
292
310
  attrs = {}
293
311
  aggregate.invariants.each do |invariant|
294
- next if Bluebook::Expression::Evaluator.call(invariant.canonical, state, attrs)
312
+ next if Bluebook::Expression::Evaluator.call_rule(invariant, state, attrs)
295
313
 
296
314
  raise InvariantViolation, "#{aggregate.hecks_name} refused — #{invariant.description}"
297
315
  end
@@ -336,7 +354,7 @@ module Hecks
336
354
  attrs = { parent: owner_instance.state }
337
355
 
338
356
  entity.invariants.each do |invariant|
339
- next if Bluebook::Expression::Evaluator.call(invariant.canonical, element_state, attrs)
357
+ next if Bluebook::Expression::Evaluator.call_rule(invariant, element_state, attrs)
340
358
 
341
359
  raise InvariantViolation, "#{entity.hecks_name} refused — #{invariant.description}"
342
360
  end