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
@@ -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
@@ -111,9 +111,7 @@ module Hecks
111
111
  op = sign.positive? ? "increment" : "decrement"
112
112
  current ||= 0
113
113
 
114
- if current.is_a?(Value) && amount.is_a?(Value)
115
- return arithmetic_value_object(current, amount, target, sign, op)
116
- end
114
+ return arithmetic_value_object(current, amount, target, sign, op) if current.is_a?(Value) && amount.is_a?(Value)
117
115
 
118
116
  # `current` genuinely absent (no declared default, never set) and
119
117
  # `amount` arrives VO-wrapped — a real command argument typed the
@@ -145,10 +143,30 @@ module Hecks
145
143
  op: op, target: target, offered: Rendering.describe(current))
146
144
  end
147
145
 
148
- current + (sign * amount)
146
+ bounded(current + (sign * amount), op, current, sign * amount, sign.positive? ? "+" : "-")
147
+ end
148
+
149
+ # C3.3/C3.4 — an effect's arithmetic is held to the same value
150
+ # model an expression's is: Integer is signed 64-bit, Float is
151
+ # finite. A result outside that is an evaluation FAULT (never a
152
+ # refusal, C8.3), worded as the Rust kernel's own generated
153
+ # `checked_add`/`checked_sub`/`checked_mul` word it.
154
+ INT64_RANGE = (-(2**63))..((2**63) - 1)
155
+
156
+ def bounded(result, oper, lhs, rhs, symbol)
157
+ if result.is_a?(Integer)
158
+ return result if INT64_RANGE.cover?(result)
159
+
160
+ raise Bluebook::Expression::EvaluationError,
161
+ "#{oper} overflowed: #{lhs} #{symbol} #{rhs.abs} does not fit in a 64-bit integer"
162
+ end
163
+ return result unless result.is_a?(Float) && !result.finite?
164
+
165
+ raise Bluebook::Expression::EvaluationError,
166
+ "#{oper} overflowed: #{lhs} #{symbol} #{rhs.abs} is not a finite number"
149
167
  end
150
168
 
151
- def arithmetic_value_object(current, amount, target, sign, op)
169
+ def arithmetic_value_object(current, amount, target, sign, oper)
152
170
  current_fields = current.to_h
153
171
  amount_fields = amount.to_h
154
172
  # Widened from Integer to Numeric -- see #arithmetic's own
@@ -162,11 +180,12 @@ module Hecks
162
180
  end
163
181
  unless shared_numeric.size == 1
164
182
  raise TypeMismatch,
165
- RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op: op, target: target)
183
+ RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op: oper, target: target)
166
184
  end
167
185
 
168
186
  field = shared_numeric.first
169
- current.with(field, current[field] + (sign * amount[field]))
187
+ current.with(field, bounded(current[field] + (sign * amount[field]), oper,
188
+ current[field], amount[field], sign.positive? ? "+" : "-"))
170
189
  end
171
190
 
172
191
  # Not a bare `.find(...)&.sign || -1` — that silently answered
@@ -180,9 +199,9 @@ module Hecks
180
199
  # raise is not a real runtime path yet — it is the same
181
200
  # backstop one level down, in case a future caller reaches
182
201
  # #sign_of directly for an op that was never meant to have one.
183
- def sign_of(op)
184
- MUTATION_OPS.find { |candidate| candidate.name == op.to_s }&.sign ||
185
- raise(WiringError, "no sign declared for mutation op #{op.inspect} — add one before calling #sign_of")
202
+ def sign_of(oper)
203
+ MUTATION_OPS.find { |candidate| candidate.name == oper.to_s }&.sign ||
204
+ raise(WiringError, "no sign declared for mutation op #{oper.inspect} — add one before calling #sign_of")
186
205
  end
187
206
 
188
207
  # Vendored addition, not (yet) upstream hecks (migration plan
@@ -196,7 +215,9 @@ module Hecks
196
215
  current ||= 0
197
216
 
198
217
  if current.is_a?(Value) && amount.is_a?(Value)
199
- return combine_value_object(current, amount, target, "multiply") { |c, a| c * a }
218
+ return combine_value_object(current, amount, target, "multiply") do |c, a|
219
+ bounded(c * a, "multiply", c, a, "*")
220
+ end
200
221
  end
201
222
 
202
223
  # Same absent-`current`, VO-wrapped-`amount` gap as `#arithmetic`
@@ -209,7 +230,7 @@ module Hecks
209
230
  offered: Rendering.describe(current.is_a?(Numeric) ? amount : current))
210
231
  end
211
232
 
212
- current * amount
233
+ bounded(current * amount, "multiply", current, amount, "*")
213
234
  end
214
235
 
215
236
  # Vendored addition, not (yet) upstream hecks (migration plan
@@ -270,7 +291,7 @@ module Hecks
270
291
  fields[numeric_fields.first]
271
292
  end
272
293
 
273
- def combine_value_object(current, amount, target, op)
294
+ def combine_value_object(current, amount, target, oper)
274
295
  current_fields = current.to_h
275
296
  amount_fields = amount.to_h
276
297
  shared_numeric = current_fields.keys.select do |field|
@@ -278,7 +299,7 @@ module Hecks
278
299
  end
279
300
  unless shared_numeric.size == 1
280
301
  raise TypeMismatch,
281
- RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op: op, target: target)
302
+ RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op: oper, target: target)
282
303
  end
283
304
 
284
305
  field = shared_numeric.first