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
@@ -4,6 +4,11 @@ require_relative "../bluebook/meta_validator"
4
4
  require_relative "../ports/query/in_memory"
5
5
  require_relative "../query_specification/field_path"
6
6
  require_relative "../runtime/value"
7
+ require_relative "properties/lifecycle_and_replay"
8
+ require_relative "properties/querying"
9
+ require_relative "properties/guards"
10
+ require_relative "properties/dispatch_and_mutations"
11
+ require_relative "properties/invariants_and_aggregation"
7
12
 
8
13
  module Hecks
9
14
  module Fuzzing
@@ -36,6 +41,22 @@ module Hecks
36
41
  # discipline) or an explicit, reasoned exemption in that same
37
42
  # spec — never silence.
38
43
  module Properties
44
+ # Grouped by responsibility across properties/*.rb (lifecycle_and_
45
+ # replay, querying, guards, dispatch_and_mutations, invariants_and_
46
+ # aggregation) — `extend`ed here rather than `include`d, matching
47
+ # `module_function` below: every property is reachable as
48
+ # `Properties.foo(history)`, a module-level call with no instance in
49
+ # play, the same relationship AggregateBuilder::Sealing's `include`
50
+ # has to instance methods and BluebookBuilder::Validation's `extend`
51
+ # has to `self.` methods — the same "one namespace, its methods,
52
+ # filed across files by responsibility" pattern, `module_function`
53
+ # already made this module's own particular shape.
54
+ extend LifecycleAndReplay
55
+ extend Querying
56
+ extend Guards
57
+ extend DispatchAndMutations
58
+ extend InvariantsAndAggregation
59
+
39
60
  module_function
40
61
 
41
62
  # WHICH LANGUAGE FEATURE EACH PROPERTY IS ANSWERABLE FOR. Not
@@ -117,30 +138,34 @@ module Hecks
117
138
  # confirmed live before the fix (SafeDepositBox's Visit/KeyIssuance —
118
139
  # see spec/runtime/safe_deposit_box_spec.rb).
119
140
  GUARANTEED_BY_CONSTRUCTION = {
120
- "Aggregate#attributes" => "every field's pattern/closed-set/type passes through Value.build's one coercion door " \
121
- "(value/coercion.rb#check_patterns, value/admission.rb) before it can exist — a stored value that violated " \
122
- "its own declared shape was never producible to begin with",
141
+ "Aggregate#attributes" => "every field's pattern/closed-set/type passes through Value.build's one coercion " \
142
+ "door (value/coercion.rb#check_patterns, value/admission.rb) before it can exist " \
143
+ "— a stored value that violated its own declared shape was never producible to " \
144
+ "begin with",
123
145
  "Aggregate#value_objects" => "the shape being coerced above — same door, same guarantee",
124
146
  # S17, ADR 0026 — the list `saga_advances_follow_declared_handlers`
125
147
  # (below) already walks to find each handler's own event_type/
126
148
  # from_state/to_state (the three it claims) — a property cannot
127
149
  # check a handler's own fields without iterating the list that
128
150
  # holds them, so the list itself is exercised by the same door.
129
- "ProcessManager#handlers" => "saga_advances_follow_declared_handlers already walks this list to find event_type/from_state/to_state — same door, same guarantee",
151
+ "ProcessManager#handlers" => "saga_advances_follow_declared_handlers already walks this list to find " \
152
+ "event_type/from_state/to_state — same door, same guarantee",
130
153
  "Aggregate#identified_by" => "CommandInterpreter's data-driven dispatch order refuses AlreadyExists " \
131
- "(command_interpreter.rb, command.creates?) for every creating command uniformly, before a duplicate id " \
132
- "can ever be stored — collision is refused at the door, not produced and later caught",
154
+ "(command_interpreter.rb, command.creates?) for every creating command uniformly, " \
155
+ "before a duplicate id can ever be stored — collision is refused at the door, not " \
156
+ "produced and later caught",
133
157
  "Entity#identified_by" => "MutationApplier#check_entity_collision (command_interpreter/mutation_applier.rb) " \
134
- "checks Array(current) against every part of the entity's own identity before an append can land, on " \
135
- "both branches identity arrives by (caller-supplied, or composite) — the same AlreadyExists refusal " \
136
- "Aggregate#identified_by gets above, one level down. Auto-minted entities never reach the check " \
137
- "(current.size + 1 can't repeat unless something remove:s from the list between mints, which no real " \
138
- "domain does today see the comment on #entity_element itself)",
158
+ "checks Array(current) against every part of the entity's own identity before an " \
159
+ "append can land, on both branches identity arrives by (caller-supplied, or " \
160
+ "composite) — the same AlreadyExists refusal Aggregate#identified_by gets above, " \
161
+ "one level down. Auto-minted entities never reach the check (current.size + 1 " \
162
+ "can't repeat unless something remove:s from the list between mints, which no " \
163
+ "real domain does today — see the comment on #entity_element itself)",
139
164
  "Command#attributes" => "command arguments are coerced through the SAME Value.build door as any other " \
140
165
  "attribute — an accepted dispatch's own args already passed pattern/admits/invariant checks",
141
- "Command#emits" => "CommandRules::Emission#emit iterates command.emits ITSELF to construct every announced " \
142
- "Event (command_rules/emission.rb) — there is no other path to emit, so a command can never announce a " \
143
- "name its own declaration doesn't list",
166
+ "Command#emits" => "CommandRules::Emission#emit iterates command.emits ITSELF to construct every " \
167
+ "announced Event (command_rules/emission.rb) — there is no other path to emit, so " \
168
+ "a command can never announce a name its own declaration doesn't list",
144
169
  "Query#attributes" => "query arguments are coerced through the same Value.build door — same guarantee as " \
145
170
  "Command#attributes",
146
171
  "Entity#attributes" => "same coercion door, one level in — an entity's own attributes are Value-typed exactly " \
@@ -148,7 +173,8 @@ module Hecks
148
173
  "ValueObject#attributes" => "the shape Value.build enforces IS this declaration — the guarantee and the " \
149
174
  "feature are the same fact seen from two sides",
150
175
  "ValueObject#invariants" => "run inside the SAME coercion call (coercion.rb, before construction returns) " \
151
- "that pattern-checks a VO's fields — a VO whose invariant did not hold could not finish being built",
176
+ "that pattern-checks a VO's fields — a VO whose invariant did not hold could not " \
177
+ "finish being built",
152
178
  "ValueObject#rows" => "closed-set membership is checked in value/admission.rb, the second half of the same " \
153
179
  "one construction door",
154
180
  # S17, ADR 0026 — Member is a genuine entity now (nested under
@@ -163,1056 +189,6 @@ module Hecks
163
189
  "Member#pairs" => "one level into ValueObject#rows — same door"
164
190
  }.freeze
165
191
 
166
- # Every lifecycle field a replay leaves an instance holding is one
167
- # of the aggregate's OWN declared states — the full set, not just
168
- # `Lifecycle#states`' default+targets (see ModelCheck.full_states'
169
- # own comment on that hole). The tie to M2 is direct: the model
170
- # checker proves which states a domain's OWN declarations can ever
171
- # produce ; this proves a REAL RUN never produced anything else —
172
- # a coercion bug, a stale string surviving a rename, a default
173
- # that drifted from the declared set, would all show up here as a
174
- # value nothing upstream would have predicted.
175
- def lifecycle_values_are_declared(history)
176
- bluebook = history.fetch(:bluebook)
177
- declared = {}
178
- bluebook.aggregates.each do |aggregate|
179
- declared[aggregate.hecks_name] = Bluebook::ModelCheck.full_states(aggregate.lifecycle) if aggregate.lifecycle
180
- end
181
- return true if declared.empty?
182
-
183
- offenders = history.fetch(:instances).filter_map do |key, state|
184
- aggregate_name = key.split("::").last.split("#").first
185
- states = declared[aggregate_name]
186
- next unless states
187
-
188
- lifecycle = bluebook.aggregate(aggregate_name).lifecycle
189
- value = state[lifecycle.field]
190
- next if value.nil? || states.include?(value.to_s)
191
-
192
- "#{key} holds #{lifecycle.field}=#{value.inspect}, which #{aggregate_name} never declares as a state"
193
- end
194
-
195
- offenders.empty? || offenders.join("; ")
196
- end
197
-
198
- # Every saga advance a replay actually logged moved along an edge
199
- # the process manager DECLARED — `(from, to)` pairs that appear in
200
- # `saga_log` with `advanced: true` must be a `(handler.from_state,
201
- # handler.to_state)` pair some handler on that PM declares
202
- # (compensation edges included ; a REFUSED-triggered advance is a
203
- # handler like any other). A saga that advanced along a pair no
204
- # handler names would mean the runtime moved state the language
205
- # never authorized — the same trust ModelCheck's static reachability
206
- # rests on, checked here against what a run actually did.
207
- def saga_advances_follow_declared_handlers(history)
208
- bluebook = history.fetch(:bluebook)
209
- edges = Hash.new { |h, k| h[k] = [] }
210
- bluebook.process_managers.each do |pm|
211
- pm.handlers.each { |handler| edges[pm.name] << [handler.from_state, handler.to_state] }
212
- end
213
- return true if edges.empty?
214
-
215
- offenders = history.fetch(:sagas).filter_map do |entry|
216
- next unless entry[:advanced]
217
-
218
- pair = [entry[:from], entry[:to]]
219
- next if edges[entry[:process_manager]].include?(pair)
220
-
221
- "#{entry[:process_manager]} advanced #{pair.inspect}, which no declared handler names"
222
- end
223
-
224
- offenders.empty? || offenders.join("; ")
225
- end
226
-
227
- # THE FOUNDATIONAL ONE. `Hecks::Runtime` mints nothing — every
228
- # identity is declared and derived, never invented (see
229
- # command_interpreter.rb's own "NOTHING IS MINTED" — a random hex,
230
- # a counter, anything not reproducible from the payload, was
231
- # refused out of the runtime specifically because it broke this).
232
- # So the SAME steps, replayed against a FRESH boot, must produce
233
- # BYTE-IDENTICAL events, refusals, and instances — any drift here
234
- # is nondeterminism the runtime promised not to have: a wall-clock
235
- # read that leaked into compared state, a Hash iteration order a
236
- # comparison depended on, anything. Two independent replays, not a
237
- # cached one compared to itself, so a bug that corrupts the FIRST
238
- # run's own bookkeeping cannot pass by agreeing with itself.
239
- def replay_is_deterministic(domain_path, steps, adapter: :memory)
240
- first = Replay.call(domain_path, steps, adapter: adapter)
241
- second = Replay.call(domain_path, steps, adapter: adapter)
242
-
243
- comparable = ->(history) { history.reject { |key, _| key == :bluebook || key == :bluebooks } }
244
- return true if comparable.call(first) == comparable.call(second)
245
-
246
- "two replays of the same #{steps.length} steps produced different histories"
247
- end
248
-
249
- # THE QUERY ORACLE — differential testing within the one runtime,
250
- # the shape the retired cross-runtime harness should always have
251
- # been. Every generated ask was answered twice at the same instant
252
- # (Replay records both): once through whatever the aggregate is
253
- # actually bound to (Memory's native hook is Ports::Query::InMemory;
254
- # a SQL binding would compile it), once through the reference
255
- # interpreter's own evaluation. The two are separate, live
256
- # implementations of the same comparator vocabulary, and they have
257
- # drifted before — an adapter that ACCEPTS what the reference says
258
- # matches nothing, or orders what it refuses to order, shows up
259
- # here as a finding no self-referential adapter spec could see.
260
- # M23 — `Replay` now runs the native and reference engines
261
- # INDEPENDENTLY (each in its own begin/rescue — see that file's own
262
- # comment at the capture site), so this property can tell apart what
263
- # used to be indistinguishable: "both engines refused" (fine — the
264
- # ask was genuinely bad, nothing to compare) from "one refused and
265
- # the other did not" (a real divergence — the two engines disagree
266
- # about whether the ask was even VALID, never mind what it answers).
267
- # `native_refused`/`reference_refused` are read by KEY PRESENCE, not
268
- # truthiness — `Replay` only ever adds `:error`/`:reference_error`
269
- # to an entry when that side actually raised, so an absent key is an
270
- # unambiguous "this side answered." A read-model ask (no reference
271
- # twin attempted at all, `asked[:query]` without "::") is skipped
272
- # entirely, same as always — there is no second engine to disagree
273
- # with.
274
- def query_answers_match_reference(history)
275
- offenders = history.fetch(:queries).filter_map do |asked|
276
- next unless asked[:query].is_a?(String) && asked[:query].include?("::")
277
-
278
- native_refused = asked.key?(:error)
279
- reference_refused = asked.key?(:reference_error)
280
-
281
- if native_refused != reference_refused
282
- next "#{asked[:query]} #{asked[:args].inspect} — native #{native_refused ? "refused (#{asked[:error]})" : 'answered'}, " \
283
- "but the reference interpreter #{reference_refused ? "refused (#{asked[:reference_error]})" : 'answered'} — " \
284
- "a refusal-shaped divergence, not just a differing row set"
285
- end
286
-
287
- next if native_refused
288
- next if asked[:rows] == asked[:reference_rows]
289
-
290
- "#{asked[:query]} #{asked[:args].inspect} answered #{asked[:rows].inspect} " \
291
- "natively but #{asked[:reference_rows].inspect} through the reference interpreter"
292
- end
293
-
294
- offenders.empty? || offenders.join("; ")
295
- end
296
-
297
- # THE SAME "TWO ENGINES, COMPARED" SHAPE query_answers_match_reference
298
- # already uses, aimed squarely at Query#options' offset/limit pair —
299
- # but recomputed from history[:instances] directly, a THIRD,
300
- # independent computation, rather than comparing QueryInterpreter's
301
- # own native and reference paths against each other (which could
302
- # share the identical bug neither implementation happened to hit —
303
- # see #4's own fix, which touched BOTH #interpret and
304
- # #reference_interpret at once). `order_by` declared alongside
305
- # `offset` or `limit` names a genuinely paged query. Ports::Query::
306
- # Ordering.apply is the SAME engine QueryInterpreter#ordered calls,
307
- # reused here rather than re-derived, so this oracle cannot drift
308
- # from what "in order" means without the interpreter drifting the
309
- # identical way — only the offset-then-limit .drop/.first slice
310
- # (#4's own fix) is independently reproduced, in plain Ruby.
311
- #
312
- # Real target: ATMCard.ByFee (`limit 3; offset 1`).
313
- def paging_offset_partitions_correctly(history)
314
- bluebooks = history.fetch(:bluebooks)
315
-
316
- offenders = history.fetch(:queries).filter_map do |asked|
317
- next if asked[:error] || !asked[:query].is_a?(String) || !asked[:query].include?("::")
318
-
319
- declared = query_for_verb(bluebooks, asked[:query])
320
- next unless declared && declared.order_by && (declared.offset || declared.limit)
321
-
322
- domain, aggregate_name, = Naming.split_verb(asked[:query])
323
- args = asked[:args] || {}
324
- rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args,
325
- bluebooks: bluebooks)
326
- ordered = Ports::Query::Ordering.apply(
327
- rows, declared.order_by, declared.null_semantics, identity: ->(row) { row[:id].to_s }
328
- ) { |row| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, declared.order_by.field)) }
329
-
330
- skipped = declared.offset ? ordered.drop(resolve_paging_value(declared.offset.value, args).to_i) : ordered
331
- expected = declared.limit ? skipped.first(resolve_paging_value(declared.limit.value, args).to_i) : skipped
332
- actual = asked[:rows]
333
- next if actual == expected
334
-
335
- "#{asked[:query]} #{args.inspect} answered #{actual.inspect}, but independently recomputing " \
336
- "order/offset/limit from #{rows.length} eligible row(s) gives #{expected.inspect}"
337
- end
338
-
339
- offenders.empty? || offenders.join("; ")
340
- end
341
-
342
- # THE DECLARED Query ITSELF, resolved from a replayed verb — the
343
- # same shape #command_for_verb resolves a command by, one
344
- # construct over. Entity-level queries (a dotted query_path) are
345
- # out of scope here — paging on an entity's own list has no real
346
- # corpus site yet, and the "one many-side head, one aggregate,
347
- # no FK-join" shape #query_eligible_rows assumes doesn't hold for
348
- # one.
349
- def query_for_verb(bluebooks, verb)
350
- domain, aggregate_name, query_path = Naming.split_verb(verb)
351
- return nil unless query_path && !query_path.include?(".")
352
-
353
- bluebook = bluebooks[domain]
354
- aggregate = bluebook&.aggregate(aggregate_name)
355
- aggregate&.query(query_path)
356
- end
357
-
358
- # A QUERY'S OWN ROWS — unlike #eligible_rows (a ReadModel's
359
- # reduced/grouped many-side head, possibly FK-joined against a
360
- # root), a Query always asks about its OWN owning aggregate
361
- # directly ; no join, no reference_target. `id:` merged in the
362
- # same way #eligible_rows' own rows are, since a stable sort
363
- # (Ordering.apply's own `identity:`) and the real answer's own
364
- # `record.state.merge(id: record.id)` both need it.
365
- # `bluebooks:` — needed ONLY to recognise and resolve a `/` HOP
366
- # clause (`engagement/client/status`, hop_chain.bluebook's own
367
- # PricedAboveViaEngagement): a hop's head names one of the OWNING
368
- # aggregate's declared references, and only the declaration graph
369
- # can say which attribute that is and which aggregate it targets.
370
- # A local clause never consults it. Latent gap this closed, found
371
- # by the fuzzer itself the first time a generated sequence ever
372
- # built a full hop chain AND had its paged query answer a row
373
- # (seed 1, the moment scalar_value_objects.bluebook joined the
374
- # fixtures corpus and shifted every seeded draw): the recompute
375
- # dug `engagement/client/status` as a LOCAL dotted path, found
376
- # nil, and declared every genuinely-eligible row ineligible — a
377
- # false property violation against a correct runtime answer,
378
- # reproducible on an untouched main with this same 4-step script.
379
- def query_eligible_rows(instances, domain, aggregate_name, wheres, args, bluebooks: {})
380
- aggregate = bluebooks[domain]&.aggregate(aggregate_name)
381
- prefix = "#{domain}::#{aggregate_name}#"
382
- instances.filter_map do |key, state|
383
- next unless key.start_with?(prefix)
384
-
385
- row = state.merge(id: key.split("#").last)
386
- next unless wheres.all? do |clause|
387
- resolved = resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
388
- held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, resolved.field))
389
- Ports::Query::InMemory.holds?(resolved, held, args)
390
- end
391
-
392
- row
393
- end
394
- end
395
-
396
- # `Runtime::ReferenceHop#fold`, independently restated over the
397
- # replay's own `:instances_at` snapshot instead of live
398
- # repositories — the same shape every other recompute in this
399
- # file takes (never the runtime's own code path, or the property
400
- # would be checking the runtime against itself). One hop peels
401
- # off the head (`HopPath.next_hop`, the identical one-step
402
- # primitive the live fold uses), the inner clause recurses
403
- # through `query_eligible_rows` against the TARGET's own
404
- # snapshot rows (so a multi-hop tail resolves hop by hop, exactly
405
- # as the live path's own recursion does), and the ids that
406
- # answered fold back as the same local `in` membership clause the
407
- # live fold builds. A clause with no `/`, or one whose head this
408
- # aggregate's declarations cannot resolve, passes through
409
- # untouched and evaluates locally as it always did.
410
- def resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
411
- return clause unless aggregate && QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
412
-
413
- hop, rest = QuerySpecification::HopPath.next_hop(clause.field, aggregate.attributes)
414
- target = hop.target
415
- return clause unless target
416
-
417
- inner = QuerySpecification::Common::WhereClause.new(field: rest, op: clause.op, value: clause.value)
418
- ids = query_eligible_rows(instances, domain, target.hecks_name, [inner], args, bluebooks: bluebooks)
419
- .map { |row| row[:id].to_s }.uniq
420
-
421
- QuerySpecification::Common::WhereClause.new(field: hop.attribute.name, op: "in", value: ids)
422
- end
423
-
424
- # `QueryInterpreter#resolve_query_value`, reproduced: a declared
425
- # limit/offset is either a literal or a Symbol naming an argument
426
- # the caller supplied.
427
- def resolve_paging_value(value, args)
428
- value.is_a?(Symbol) ? args[value] : value
429
- end
430
-
431
- # `Query#options`' OTHER HALF — TenantScope.apply's own contract
432
- # (tenant_scope.rb), independently restated as a property rather
433
- # than exercised only through whatever the generator happens to
434
- # try. NOT closed by the generator here on purpose: SafeDepositBox.
435
- # Rented — the only real corpus query declaring `authorize` at
436
- # all — declares ZERO attributes of its own, so StepBuilder#args_for
437
- # always hands it `{}` and TenantScope.apply refuses every
438
- # generated attempt, unconditionally (confirmed: no successful ask
439
- # against an authorize-bearing query reaches this property via the
440
- # standard battery today). Extending the generator to invent a
441
- # `tenant:` value ran into a separate, real finding along the way —
442
- # SafeDepositBox is COMPOSITE-identified (`identified_by` is nil
443
- # for it — Runtime::Identified#derive_identity), so the generator's
444
- # existing `known_ids` pool (keyed by `aggregate.identified_by ||
445
- # "id"`) tracks a stray, never-real scalar for it rather than its
446
- # true `branch_code`+`box_number` pair — a second, narrower
447
- # generator gap this property does not attempt to fix, since fixing
448
- # it well enough to trust a generated `tenant:` value would be the
449
- # heavier, "benefits every future property" path the plan itself
450
- # names as the alternative. Hand-built fixtures close the real
451
- # claim directly instead: faster, narrower, and correct either way,
452
- # since TenantScope.apply's contract is identical regardless of
453
- # where a `tenant:` arg came from.
454
- #
455
- # Two claims, matching TenantScope.apply's own two branches: every
456
- # SUCCESSFUL answer's own tenant field agrees with the tenant arg
457
- # given (the WhereClause TenantScope injects is a Symbol reference
458
- # into args, resolved dynamically — this checks the OUTCOME, not
459
- # re-deriving that resolution) ; every ask MISSING a required
460
- # tenant: refuses with the declared wording, never succeeds. A
461
- # refusal for an unrelated reason with the tenant arg present is
462
- # not this property's claim either way — skipped, not graded.
463
- def authorize_scopes_or_refuses(history)
464
- bluebooks = history.fetch(:bluebooks)
465
-
466
- offenders = history.fetch(:queries).filter_map do |asked|
467
- next unless asked[:query].is_a?(String) && asked[:query].include?("::")
468
-
469
- declared = query_for_verb(bluebooks, asked[:query])
470
- tenant = declared&.authorization&.tenant&.to_sym
471
- next unless tenant
472
-
473
- args = asked[:args] || {}
474
- tenant_given = args.key?(tenant)
475
-
476
- if asked[:error]
477
- next if tenant_given
478
- next if asked[:error].to_s.include?("declares authorize with tenant: #{tenant}")
479
-
480
- "#{asked[:query]} #{args.inspect} refused with no #{tenant}: given, but not with the declared " \
481
- "tenant_required wording (#{asked[:error]})"
482
- elsif !tenant_given
483
- "#{asked[:query]} #{args.inspect} answered successfully with no #{tenant}: given, but #{declared.name} " \
484
- "declares authorize with tenant: #{tenant}"
485
- else
486
- wanted = args[tenant].to_s
487
- mismatched = asked[:rows].find do |row|
488
- Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, tenant)).to_s != wanted
489
- end
490
- next unless mismatched
491
-
492
- "#{asked[:query]} #{args.inspect} answered a row whose #{tenant} disagrees with the given " \
493
- "#{wanted.inspect}: #{mismatched.inspect}"
494
- end
495
- end
496
-
497
- offenders.empty? || offenders.join("; ")
498
- end
499
-
500
- # EVERY GIVEN/ENSURES REFUSAL A RUN ACTUALLY RAISED NAMES A RULE
501
- # THE COMMAND ACTUALLY DECLARES. `GivenNotMet`/`EnsuresNotMet` both
502
- # quote their guard's own `description` verbatim
503
- # (command_rules/admissibility.rb: `"#{command.hecks_name} refused
504
- # — #{given.description}"`) — the SAME text `behavior.bluebook`'s
505
- # own `Rule`/Command.Ensure hold as `Rule#description`, so a
506
- # refusal whose quoted text is not among the refusing command's
507
- # OWN `guard_descriptions` (Behaviour::Command, both givens and
508
- # ensures) is either a stale message surviving a renamed rule, a
509
- # rule firing against the wrong command's own guard set, or the
510
- # wording drifting out from under the declaration it is supposed
511
- # to quote — banking's own 128 status givens (customer/account
512
- # guards, some through a cross-aggregate dereference) are exactly
513
- # the surface this exists to hold to its word.
514
- #
515
- # `kind:` is what tells a guard refusal apart from the FOUR other
516
- # `RefusalWording` templates sharing the identical "X refused — Y"
517
- # shape (LifecycleRefused/transition_blocked, both TypeMismatch
518
- # object-reference templates, Unauthorized/role_mismatch) — see
519
- # Replay's own comment at the refusal rescue site. Pattern-matching
520
- # the string alone would confuse a guard's own wording with any of
521
- # those; the raised class does not.
522
- GUARD_REFUSAL_KINDS = %w[Hecks::Runtime::GivenNotMet Hecks::Runtime::EnsuresNotMet].freeze
523
-
524
- def guard_refusals_are_declared(history)
525
- bluebooks = history.fetch(:bluebooks)
526
-
527
- offenders = history.fetch(:refusals).filter_map do |refusal|
528
- next unless GUARD_REFUSAL_KINDS.include?(refusal[:kind])
529
-
530
- match = refusal[:error].to_s.match(/\A(.+) refused — (.+)\z/)
531
- next "#{refusal[:verb]} raised #{refusal[:kind]} with unparseable message #{refusal[:error].inspect}" unless match
532
-
533
- command = command_for_verb(bluebooks, refusal[:verb])
534
- next "#{refusal[:verb]} raised #{refusal[:kind]}, but no declared command resolves that verb" unless command
535
-
536
- declared = effective_guard_descriptions(bluebooks, refusal[:verb], command)
537
- next if declared.include?(match[2])
538
-
539
- "#{refusal[:verb]} refused — #{match[2].inspect} — but #{command.hecks_name} declares no given " \
540
- "or ensures with that description (it declares #{declared.inspect})"
541
- end
542
-
543
- offenders.empty? || offenders.join("; ")
544
- end
545
-
546
- # A DECLARED PROCESS MANAGER'S OWN COMMAND — `command.hecks_name`,
547
- # or an entity's own if the verb's second component is itself
548
- # dotted (`Aggregate.Entity.Command`, the same two shapes
549
- # `Dispatcher#dispatch` itself branches on). Shared by the guard
550
- # property above and available for anything else that needs to go
551
- # from a replayed verb back to its declaration.
552
- #
553
- # RESOLVED AGAINST `bluebooks` (the FULL map, `history[:bluebooks]`
554
- # — every loaded domain, keyed by name), never a single assumed
555
- # bluebook: a verb names its OWN domain (`Naming.split_verb`'s
556
- # first element), and that domain is not always the one Replay
557
- # happens to expose as `history[:bluebook]`. A fuzz run against
558
- # `lib/hecks/grammar` (Expression + Translation, in load
559
- # order) found this the hard way — every `Translation::Map.Seal`
560
- # refusal read as "no declared command resolves that verb" purely
561
- # because `history[:bluebook]` was Expression, not Translation; the
562
- # refusal was real, this property's own domain resolution was not.
563
- # A DELEGATING DOOR REFUSES WITH ITS TARGET'S OWN WORDS. `delegates_to`
564
- # (CommandBuilder#delegates_to_impl) hands the whole dispatch to one
565
- # entity command, and that command's given is what refuses — raised
566
- # back through the door, in the door's name (chess: `Game.MoveKnight
567
- # refused — "it is that color's turn"`, a given Knight.Move declares
568
- # and MoveKnight, a pure passthrough, never could). Read the door's
569
- # own guards first, then every delegation target's; an offence is
570
- # only a description NEITHER declares. Found live mining chess's
571
- # history: every refused move through a door read as undeclared.
572
- def effective_guard_descriptions(bluebooks, verb, command)
573
- own = command.guard_descriptions
574
- delegated = command.mutations.select { |m| m.op == :delegate }.flat_map do |delegation|
575
- domain, aggregate_name, = Naming.split_verb(verb)
576
- target = command_for_verb(bluebooks, "#{domain}::#{aggregate_name}.#{delegation.target}")
577
- target ? target.guard_descriptions : []
578
- end
579
- own + delegated
580
- end
581
-
582
- def command_for_verb(bluebooks, verb)
583
- domain, aggregate_name, command_path = Naming.split_verb(verb)
584
- return nil unless command_path
585
-
586
- bluebook = bluebooks[domain]
587
- return nil unless bluebook
588
-
589
- aggregate = bluebook.aggregate(aggregate_name)
590
- return nil unless aggregate
591
-
592
- if command_path.include?(".")
593
- entity_name, sub = command_path.split(".", 2)
594
- entity = aggregate.entities.find { |e| e.hecks_name == entity_name }
595
- entity&.command(sub)
596
- else
597
- aggregate.command(command_path)
598
- end
599
- end
600
-
601
- # `guard_refusals_are_declared`'s OWN OPPOSITE DIRECTION. That
602
- # property is passive and one-directional — for a refusal that
603
- # ALREADY HAPPENED, is the quoted text real declared text? It says
604
- # nothing about a guard that should have refused and silently did
605
- # not — a call site that stopped calling enforce_givens/enforce_
606
- # lifecycle_guard would never appear in history[:refusals] at all,
607
- # invisible to that property by construction.
608
- #
609
- # This one calls Admissibility#enforce_givens (which itself folds
610
- # in #enforce_lifecycle_guard whenever `declaring:` is passed)
611
- # DIRECTLY, against Replay's own pre-dispatch snapshot
612
- # (history[:guard_checks], one bounded, additive extension — see
613
- # that file's own comment at the capture site) — an independent
614
- # recomputation, not grading production against itself, the same
615
- # "two engines, compared" shape query_answers_match_reference and
616
- # the fan-out oracle already establish. `recomputed_refused`
617
- # (Replay's own call, made live, before this step's real dispatch
618
- # could mutate anything a cross-aggregate given dereferences) is
619
- # compared against `actual_refused` (GivenNotMet/LifecycleRefused
620
- # specifically — Replay's own comment on GUARD_REFUSAL_CLASSES
621
- # explains why ANY other refusal class, or an outright success,
622
- # both count as "the guard did not fire," since enforce_givens
623
- # runs FIRST in DISPATCH_ORDER).
624
- #
625
- # Aggregate#preconditions closes for free alongside this — a
626
- # no-block `given` reference (CommandBuilder#given) pushes the
627
- # SAME Given struct object `enforce_givens` already iterates
628
- # command.givens for, so there is no separate runtime path a
629
- # property could exercise beyond what this already reaches.
630
- # Entity#preconditions closes the identical way, one level down
631
- # (ADR 0028) — a piece's own bare `given` reference pushes the
632
- # SAME Given struct onto ITS OWN referencing command's givens,
633
- # so LedgerEntry's own Amend/Reverse (banking) already exercise
634
- # this through the exact mechanism above, no separate path.
635
- #
636
- # Real targets: Account.Debit/CloseAccount (`from:` guards),
637
- # Credit/Debit (the named-once `given("customer is active")`
638
- # precondition) — FreezeAccount deliberately references the
639
- # DIFFERENT named precondition `"customer is not closed"` instead
640
- # (a suspended customer must still be freezable), so it is not a
641
- # `"customer is active"` example, just the same MECHANISM.
642
- def lifecycle_guard_and_given_violations_are_refused(history)
643
- offenders = history.fetch(:guard_checks).filter_map do |check|
644
- next if check[:recomputed_refused] == check[:actual_refused]
645
-
646
- "#{check[:verb]} — independently recomputing enforce_givens/enforce_lifecycle_guard against the " \
647
- "pre-dispatch state says #{check[:recomputed_refused] ? "refused (#{check[:recomputed_kind]})" : 'admitted'}, " \
648
- "but the real dispatch #{check[:actual_refused] ? "refused (#{check[:actual_kind]})" : 'admitted it'}"
649
- end
650
-
651
- offenders.empty? || offenders.join("; ")
652
- end
653
-
654
- # Closes Handler#dispatches and — the same shape, one construct
655
- # over — Policy#with_spec (Dispatch#command_name/Dispatch#with_spec
656
- # are not claimable feature strings at all — see FEATURE_COVERAGE's
657
- # own comment on this entry). history[:saga_dispatches]/[:policy_dispatches]
658
- # (Registry#saga_dispatch_log/#policy_dispatch_log — additive,
659
- # Ruby-only, NEVER touching saga_log/reaction_log, the byte-for-
660
- # byte shape spec/rust_conformance_spec.rb holds Rust to) each
661
- # carry the RAW inputs a dispatch's own args were resolved from,
662
- # captured live at the moment the resolution actually ran — a
663
- # saga's own memory keeps changing across a run, so re-deriving
664
- # from history[:saga_instances]'s FINAL memory (the only other
665
- # place it would be visible) would grade the wrong moment,
666
- # lifecycle_guard_and_given_violations_are_refused's own false
667
- # positive one item earlier, in a different shape.
668
- #
669
- # #resolve_dispatch_binding/#resolve_trigger_binding are SEPARATE,
670
- # independently-written re-derivations of SagaInterpreter#
671
- # dispatch_args/PolicyInterpreter#trigger_args's own resolution —
672
- # never calling either method again, which would only ever agree
673
- # with itself. Exactly the class of bug this closes: "a wrong
674
- # argument binding on a fan-out dispatch that produces a perfectly
675
- # normal-looking log entry (`delivered: true`) and would only ever
676
- # surface as a downstream assertion failure, if it surfaces at
677
- # all" — PR #325's own defect class, one level over.
678
- #
679
- # Real targets: Settlement (mixed literal/correlation-head/event-
680
- # payload/memory-fallback bindings across three legs, plus a
681
- # compensation leg that deliberately omits `reference:`, a field
682
- # the forward Credit leg carries), ExternalSettlement, Onboarding
683
- # (no compensation leg, by design — nothing to check there beyond
684
- # the forward leg's own event-payload binding).
685
- def dispatch_binding_fidelity(history)
686
- saga_offenders = history.fetch(:saga_dispatches, []).filter_map do |entry|
687
- expected = resolve_dispatch_binding(entry)
688
- next if expected == entry[:args]
689
-
690
- "#{entry[:process_manager]}##{entry[:instance]} dispatching #{entry[:dispatch]} on #{entry[:on]} — " \
691
- "bound #{entry[:args].inspect}, but independently re-deriving with_spec's own resolution gives " \
692
- "#{expected.inspect}"
693
- end
694
-
695
- policy_offenders = history.fetch(:policy_dispatches, []).filter_map do |entry|
696
- expected = resolve_trigger_binding(entry)
697
- next if expected == entry[:args]
698
-
699
- "#{entry[:policy]} on #{entry[:on]} — bound #{entry[:args].inspect}, but independently re-deriving " \
700
- "with_spec's own resolution gives #{expected.inspect}"
701
- end
702
-
703
- offenders = saga_offenders + policy_offenders
704
- offenders.empty? || offenders.join("; ")
705
- end
706
-
707
- # SagaInterpreter#dispatch_args's own 4-branch resolution,
708
- # reproduced independently: a literal, the correlation key itself,
709
- # the CURRENT triggering event's own payload, or — the fallback —
710
- # the saga's own carried memory (seeded from the STARTING event's
711
- # payload, at begin_saga).
712
- def resolve_dispatch_binding(entry)
713
- entry[:with_spec].to_h do |key, value|
714
- resolved = if !value.is_a?(Symbol) then value
715
- elsif value == entry[:correlation_head] then entry[:instance]
716
- elsif entry[:event_payload].key?(value) then entry[:event_payload][value]
717
- else entry[:memory][value]
718
- end
719
- [key.to_sym, Runtime::Value.materialize(resolved)]
720
- end
721
- end
722
-
723
- # PolicyInterpreter#trigger_args's own 2-branch resolution — a
724
- # policy holds no correlation and no memory, so `payload` (the
725
- # triggering event's own payload, already merged with a fan-out
726
- # row's id when there is one) is the WHOLE source.
727
- def resolve_trigger_binding(entry)
728
- entry[:with_spec].to_h do |key, value|
729
- resolved = value.is_a?(Symbol) ? entry[:payload][value] : value
730
- [key.to_sym, Runtime::Value.materialize(resolved)]
731
- end
732
- end
733
-
734
- # Closes Command#mutations — the last of the five, and the
735
- # largest: no real corpus entity anywhere uses `append`/`remove`/
736
- # `multiply`/`clamp` (every real aggregate-owned mutation is
737
- # aggregate-scoped — Account.Credit's :ledger, LogVisit's
738
- # :visits); the only entity-owned use of these four ops in the
739
- # whole repository is spec/fixtures/entity_list_mutations, now a
740
- # real, bootable, Memory-default domain (no .hecksagon needed at
741
- # all — a domain with none boots every aggregate against Memory
742
- # by construction, confirmed live) rather than the raw-Kernel.
743
- # load-only fixture it was.
744
- #
745
- # `history[:mutation_traces]` (Replay's own bounded, additive
746
- # extension — see #build_mutation_trace's own comment) carries a
747
- # per-step before/after snapshot of the ENTITY ELEMENT an
748
- # entity-dispatched command's own mutations acted on, materialized
749
- # to plain data, plus the step's own raw args — the delta
750
- # `aggregation_matches_recompute` never had to ask for, because
751
- # count/median are pure functions of FINAL state and a mutation
752
- # is not (the same "captured live, not re-derived from final
753
- # state" lesson item 8's own saga_dispatch_log already learned).
754
- #
755
- # #recompute_append/#recompute_remove/#recompute_multiply/
756
- # #recompute_clamp are SEPARATE, independently-written
757
- # reproductions of MutationApplier#appended/#removed and
758
- # CommandRules::Arithmetic#multiply/#clamp — never calling either
759
- # again, which would only ever agree with itself. `:set`/
760
- # `:increment`/`:decrement` are out of scope on purpose (the four
761
- # "vendored, not yet upstream" ops this item exists for); a
762
- # command mixing them with a recomputable op still gets the
763
- # recomputable one checked.
764
- #
765
- # `:unrecomputable` (never compared, never a finding) covers the
766
- # generator's own deliberate arg-malforming (`StepBuilder#malform`)
767
- # landing a non-Numeric amount/non-2-element bounds where
768
- # multiply/clamp need one — the SAME shape `guard_check`'s own
769
- # AbsentArgument false positive taught: a step whose raw material
770
- # doesn't fit the op's own contract is inconclusive, not a claimed
771
- # mismatch.
772
- RECOMPUTABLE_MUTATION_OPS = %i[append remove multiply clamp].freeze
773
-
774
- def mutations_match_recompute(history)
775
- bluebooks = history.fetch(:bluebooks)
776
-
777
- offenders = history.fetch(:mutation_traces, []).flat_map do |entry|
778
- next [] unless entry[:after]
779
-
780
- command = command_for_verb(bluebooks, entry[:verb])
781
- next [] unless command
782
-
783
- command.mutations.select { |m| RECOMPUTABLE_MUTATION_OPS.include?(m.op) }.filter_map do |mutation|
784
- expected = recompute_mutation(mutation, entry[:before][mutation.target], entry[:args], entry[:before])
785
- next if expected == :unrecomputable
786
-
787
- actual = entry[:after][mutation.target]
788
- next if symbolize_deep(expected) == symbolize_deep(actual)
789
-
790
- "#{entry[:verb]} — #{mutation.op} on #{mutation.target} — recomputing independently gives " \
791
- "#{expected.inspect}, but the real dispatch left #{actual.inspect}"
792
- end
793
- end
794
-
795
- offenders.empty? || offenders.join("; ")
796
- end
797
-
798
- def recompute_mutation(mutation, current, args, before_scope)
799
- case mutation.op
800
- when :append then recompute_append(current, mutation.source, before_scope, args)
801
- when :remove then recompute_remove(current, mutation.source, args)
802
- when :multiply then recompute_multiply(current, resolve_mutation_source(mutation.source, args))
803
- when :clamp then recompute_clamp(current, mutation.source)
804
- end
805
- end
806
-
807
- # `MutationApplier#appended`'s own value-object branch (never the
808
- # entity_element branch — see #build_mutation_trace's own comment
809
- # on why an entity-dispatched command's own mutations never reach
810
- # it), reproduced: the field map resolved the SAME two-tier way
811
- # (`MutationApplier#resolve_append_source` — a caller-supplied
812
- # arg, or the entity's own current field), then appended.
813
- def recompute_append(current, source_map, before_scope, args)
814
- fields = source_map.transform_values { |source| resolve_mutation_append_field(source, before_scope, args) }
815
- Array(current) + [symbolize_deep(fields)]
816
- end
817
-
818
- def resolve_mutation_append_field(source, before_scope, args)
819
- return source unless source.is_a?(Symbol)
820
- return args[source] if args.key?(source)
821
-
822
- before_scope[source]
823
- end
824
-
825
- # `MutationApplier#removed`'s own value-equality match, reproduced.
826
- def recompute_remove(current, source, args)
827
- target = symbolize_deep(resolve_mutation_source(source, args))
828
- Array(current).reject { |element| symbolize_deep(element) == target }
829
- end
830
-
831
- # `CommandRules::Arithmetic#multiply`'s own two branches,
832
- # reproduced on plain materialized data instead of a real Value:
833
- # a single-numeric-field Hash (the VO-typed case — ListCount, one
834
- # Integer field) scales that field ; a bare Numeric scales itself.
835
- # `current ||= 0` — the SAME phantom-field fallback #multiply
836
- # itself already gives (unaffected by this session's #clamp fix,
837
- # since #multiply never needed one).
838
- def recompute_multiply(current, amount)
839
- return :unrecomputable unless amount.is_a?(Numeric)
840
-
841
- current ||= 0
842
- if current.is_a?(Hash)
843
- field = current.keys.find { |f| current[f].is_a?(Numeric) }
844
- return :unrecomputable unless field
845
-
846
- current.merge(field => current[field] * amount)
847
- elsif current.is_a?(Numeric)
848
- current * amount
849
- else
850
- :unrecomputable
851
- end
852
- end
853
-
854
- # `CommandRules::Arithmetic#clamp`'s own two branches, reproduced
855
- # the same way #recompute_multiply is — including THIS SESSION'S
856
- # OWN `current ||= 0` fix (command_rules/arithmetic.rb), the one
857
- # arithmetic op that didn't have it until now. `mutation.source`
858
- # is always a literal `[min, max]`, never an argument reference
859
- # (MutationApplier's own comment on why `resolve_source` is
860
- # skipped for clamp) — so nothing here reads `args` for it at all.
861
- def recompute_clamp(current, bounds)
862
- return :unrecomputable unless bounds.is_a?(Array) && bounds.size == 2
863
-
864
- min, max = bounds
865
- current ||= 0
866
- if current.is_a?(Hash)
867
- field = current.keys.find { |f| current[f].is_a?(Numeric) }
868
- return :unrecomputable unless field
869
-
870
- current.merge(field => current[field].clamp(min, max))
871
- elsif current.is_a?(Numeric)
872
- current.clamp(min, max)
873
- else
874
- :unrecomputable
875
- end
876
- end
877
-
878
- # `CommandRules::Arithmetic#resolve_source`, reproduced: a
879
- # mutation's source is either the NAME OF AN ARGUMENT or a
880
- # LITERAL, told apart by type.
881
- def resolve_mutation_source(source, args)
882
- source.is_a?(Symbol) ? args[source] : source
883
- end
884
-
885
- # A generated step's own `args` arrive with STRING keys on every
886
- # nested Hash (the wire/JSON shape `spec/corpus/*.json` already
887
- # uses) while `history[:mutation_traces]`' own materialized
888
- # before/after state carries SYMBOL keys throughout (Runtime::
889
- # Value.materialize's own convention) — two hashes holding the
890
- # identical fact compare UNEQUAL by Ruby's own `Hash#==` unless
891
- # both sides are normalized the same way first. Recursive, since
892
- # an appended/removed element can itself nest a value object
893
- # (RemoveTag's own `Tag` argument, `{"key"=>..., "value"=>...}`).
894
- def symbolize_deep(value)
895
- case value
896
- when Hash then value.to_h { |key, val| [key.to_sym, symbolize_deep(val)] }
897
- when Array then value.map { |val| symbolize_deep(val) }
898
- else value
899
- end
900
- end
901
-
902
- # EVERY STORED RECORD STILL SATISFIES ITS OWN AGGREGATE'S DECLARED
903
- # INVARIANTS — Admissibility#enforce_invariants (command_rules/
904
- # admissibility.rb) checks these AFTER every command's mutations,
905
- # BEFORE save, the same point `ensures` is checked. Nothing until
906
- # now re-checked a record AFTER a whole replay finished, independent
907
- # of whichever call site was supposed to have refused a violation
908
- # in the first place — a record failing its own declared invariant
909
- # here is proof a violating write landed anyway: the call site
910
- # stopped calling enforce_invariants, or some other path (a
911
- # translation, a backfill) wrote around it entirely.
912
- #
913
- # `history[:instances]` entries are already plain, symbol-keyed
914
- # state Hashes (Replay.call's own `record.state`) — called against
915
- # Evaluator.call the SAME way ValueObject::Builder#build already
916
- # does for a VO's own invariants (value/coercion.rb), no GuardState
917
- # wrapper needed the way enforce_invariants' own LIVE call uses one
918
- # (GuardState exists for `parent.`/projected-field dereferencing
919
- # mid-dispatch; a stored record's own scalar fields need none of
920
- # that to re-check a same-aggregate invariant against itself).
921
- #
922
- # Real target: Account's own `invariant("the balance never goes
923
- # negative") { balance.cents >= 0 }`.
924
- #
925
- # Entity#invariants (round 7) closes here too, not for free —
926
- # `stored_records_satisfy_declared_invariants` only ever checked
927
- # the AGGREGATE's own flat state; a piece's own invariant is
928
- # checked against every ELEMENT of a `list_of` field, a genuinely
929
- # different walk `check_piece_invariants` below makes,
930
- # independently of `Admissibility#check_entity_invariants` (the
931
- # live enforcement path this property exists to catch drifting
932
- # from) — same reasoning `stored_records_satisfy_declared_
933
- # invariants`' own top-level check already applies one level up.
934
- #
935
- # Real target: SafeDepositBox's own Visit — `invariant("a written
936
- # note is not blank") { !note || !note.text.to_s.empty? }`.
937
- def stored_records_satisfy_declared_invariants(history)
938
- bluebooks = history.fetch(:bluebooks)
939
-
940
- offenders = history.fetch(:instances).filter_map do |key, state|
941
- domain_name = key.split("::").first
942
- aggregate_name = key.split("::").last.split("#").first
943
- bluebook = bluebooks[domain_name]
944
- aggregate = bluebook&.aggregate(aggregate_name)
945
- next unless aggregate
946
-
947
- violated = aggregate.invariants.find do |invariant|
948
- !Bluebook::Expression::Evaluator.call(invariant.canonical, state)
949
- end
950
- next "#{key} violates #{aggregate_name}'s own declared invariant #{violated.description.inspect}" if violated
951
-
952
- check_piece_invariants(aggregate, state, key)
953
- end
954
-
955
- offenders.empty? || offenders.join("; ")
956
- end
957
-
958
- # A PIECE'S OWN INVARIANT, checked against every element a
959
- # `list_of` field holds — the SAME lookup `Admissibility#
960
- # check_entity_invariants` makes (`owner.attributes.find { |a|
961
- # a.list? && a.type.to_s == entity.hecks_name }`), independently
962
- # reapplied here against a STORED record's own plain Hash state
963
- # rather than a live `Instance`.
964
- def check_piece_invariants(owner_construct, owner_state, key)
965
- owner_construct.entities.each do |entity|
966
- next if entity.invariants.empty?
967
-
968
- list_attr = owner_construct.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
969
- next unless list_attr
970
-
971
- Array(owner_state[list_attr.name]).each do |element|
972
- violated = entity.invariants.find do |invariant|
973
- !Bluebook::Expression::Evaluator.call(invariant.canonical, element)
974
- end
975
- return "#{key}'s own #{entity.hecks_name} violates its declared invariant #{violated.description.inspect}" if violated
976
-
977
- nested = check_piece_invariants(entity, element, key)
978
- return nested if nested
979
- end
980
- end
981
- nil
982
- end
983
-
984
- # A SAGA INSTANCE'S OWN CHECKPOINT SURVIVES BEING WRITTEN AND READ
985
- # BACK — the durability contract `SagaInterpreter#checkpoint` makes
986
- # (`state:` plus a `deep_copy`d `memory:`, handed to whatever
987
- # adapter answers `save_saga`) and `Registry#rehydrate_sagas!`
988
- # promises to restore on the next boot (`each_saga` yielding
989
- # `[pm, correlation, state, memory]` back into `saga_instances`).
990
- # `Replay` captures the LIVE store already materialised the same
991
- # way `checkpoint` itself does (`Value.materialize`, not raw
992
- # `Runtime::Value`s — see its own comment); this property pushes
993
- # that captured memory through the SAME `JSON.generate` then
994
- # `JSON.parse(symbolize_names: true)` round-trip `checkpoint`'s own
995
- # `deep_copy` performs (mirrored here rather than called — a
996
- # private instance method with no registry to hand it) and checks
997
- # it comes back byte-identical. A memory holding anything that
998
- # round-trip cannot carry faithfully — a bare Symbol leaf, a
999
- # non-JSON type a future field introduces — is corruption the
1000
- # durable path would introduce on a REAL restart, caught here
1001
- # without needing one.
1002
- #
1003
- # `declares_state?` (Behaviour::ProcessManager) is the other half:
1004
- # a live or rehydrated instance sitting in a state the procedure
1005
- # never declares is the saga-durability twin of
1006
- # `lifecycle_values_are_declared` above.
1007
- def sagas_rehydrate_cleanly(history)
1008
- bluebook = history.fetch(:bluebook)
1009
- process_managers = bluebook.process_managers.to_h { |pm| [pm.name, pm] }
1010
-
1011
- offenders = history.fetch(:saga_instances).flat_map do |pm_name, conversations|
1012
- pm = process_managers[pm_name]
1013
-
1014
- conversations.filter_map do |correlation, instance|
1015
- problems = []
1016
-
1017
- problems << "holds state #{instance[:state].inspect}, which #{pm_name} never declares" if pm && !pm.declares_state?(instance[:state])
1018
-
1019
- rehydrated = JSON.parse(JSON.generate(instance[:memory]), symbolize_names: true)
1020
- if rehydrated != instance[:memory]
1021
- problems << "memory does not survive its own checkpoint round-trip " \
1022
- "(checkpointed #{instance[:memory].inspect}, rehydrated #{rehydrated.inspect})"
1023
- end
1024
-
1025
- next if problems.empty?
1026
-
1027
- "#{pm_name}##{correlation.inspect}: #{problems.join(' and ')}"
1028
- end
1029
- end
1030
-
1031
- offenders.empty? || offenders.join("; ")
1032
- end
1033
-
1034
- # A `for_each` POLICY DISPATCHES EXACTLY ONCE PER ROW ITS DECLARED
1035
- # QUERY ANSWERS — never once for the triggering event regardless of
1036
- # row count, never skipping a matched row, never firing on a row a
1037
- # concurrent mutation only made match AFTER the fact. `Replay`
1038
- # computes the expected row-id set INDEPENDENTLY, at the same
1039
- # instant the real dispatch runs (`Replay.expected_fan_out_rows`,
1040
- # the query oracle's own shape aimed at fan-out: two engines
1041
- # compared, never one graded against itself), and records it
1042
- # beside what the reaction log actually shows. `expected_row_ids`
1043
- # is `nil`, not `[]`, when `policy.where` did not hold — no
1044
- # dispatch is the claim then, not "dispatched to zero rows," and a
1045
- # policy that dispatched anyway despite a failing guard is as real
1046
- # a finding as a row it skipped.
1047
- def fanout_dispatches_once_per_matching_row(history)
1048
- offenders = history.fetch(:fan_outs).filter_map do |finding|
1049
- expected = finding[:expected_row_ids]
1050
- actual = finding[:actual_row_ids].sort
1051
-
1052
- if expected.nil?
1053
- next if actual.empty?
1054
-
1055
- next "#{finding[:policy]} on #{finding[:on]}: where did not hold, but dispatched to #{actual.inspect}"
1056
- end
1057
-
1058
- next if actual == expected
1059
-
1060
- "#{finding[:policy]} on #{finding[:on]}: for_each answered #{expected.inspect}, " \
1061
- "but the reaction log shows dispatches to #{actual.inspect}"
1062
- end
1063
-
1064
- offenders.empty? || offenders.join("; ")
1065
- end
1066
-
1067
- # A `count`/`median` REPORT'S REDUCED SCALAR MATCHES THE SAME
1068
- # REDUCTION DONE INDEPENDENTLY, over the SAME eligible rows —
1069
- # `ReadModelInterpreter#project`'s own FK-join (root first, then
1070
- # each many-side head matched against it) and `#median` (odd →
1071
- # the true middle, even → the average of the two middles as a
1072
- # Float, empty → `nil`; `count` is the filtered length, empty →
1073
- # `0`), reproduced here in plain Ruby against `history[:instances]`
1074
- # rather than a live registry — `FieldPath.dig` +
1075
- # `Ports::Query::InMemory.comparable`/`.holds?` are the SAME two
1076
- # calls the interpreter itself makes to read a field and judge a
1077
- # `where`, called here rather than re-derived, so this oracle
1078
- # cannot drift from what "read a field" or "a clause holds" mean
1079
- # without the interpreter drifting the identical way.
1080
- #
1081
- # Only a report whose `:query` is answered by the SAME bluebook
1082
- # `history[:bluebook]` carries (the bare `Domain.report_name`
1083
- # form, `domain == bluebook.name`) is checked — the same "only
1084
- # what we have the grammar for" scope `lifecycle_values_are_declared`
1085
- # already takes for a multi-domain replay.
1086
- def aggregation_matches_recompute(history)
1087
- bluebook = history.fetch(:bluebook)
1088
-
1089
- offenders = history.fetch(:queries).filter_map do |asked|
1090
- next if asked[:error]
1091
-
1092
- domain, name = asked[:query].to_s.split(".", 2)
1093
- next unless name && domain == bluebook.name
1094
-
1095
- model = bluebook.read_model(name)
1096
- next unless model && (model.count? || model.median_field)
1097
-
1098
- reduced_head = model.aggregate_heads.find { |head| head[:many] }
1099
- next unless reduced_head
1100
-
1101
- rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, reduced_head, asked[:args] || {})
1102
- expected = model.count? ? rows.length : recompute_median(rows, model.median_field)
1103
- actual = asked[:rows]&.first&.dig(reduced_head[:as])
1104
- next if actual == expected
1105
-
1106
- "#{asked[:query]} #{asked[:args].inspect} answered #{actual.inspect} for #{reduced_head[:as]}, " \
1107
- "but recomputing independently from #{rows.length} eligible row(s) gives #{expected.inspect}"
1108
- end
1109
-
1110
- offenders.empty? || offenders.join("; ")
1111
- end
1112
-
1113
- # `aggregation_matches_recompute`'s own shape, extended from
1114
- # reducing a many-side head to a scalar (count/median) to NESTING
1115
- # it — `ReadModelInterpreter#group_by_target`/`#nest`, reproduced
1116
- # here in plain Ruby against `history[:instances]` the same way
1117
- # `eligible_rows` already reproduces the FK-join and `where`
1118
- # narrowing count/median share. `Value.materialize_unwrapped` is
1119
- # the SAME call `#project` makes before nesting (a single-field
1120
- # value object recurses to its bare scalar — a real grouping key
1121
- # has to BE one) — called here rather than re-derived, so this
1122
- # oracle cannot drift from what "the group key" means without the
1123
- # interpreter drifting the identical way.
1124
- #
1125
- # Real target: AccountsByKind (`group_by :kind, :number`,
1126
- # rootless — always generator-eligible with `{}` args).
1127
- def group_by_matches_recompute(history)
1128
- bluebook = history.fetch(:bluebook)
1129
-
1130
- offenders = history.fetch(:queries).filter_map do |asked|
1131
- next if asked[:error]
1132
-
1133
- domain, name = asked[:query].to_s.split(".", 2)
1134
- next unless name && domain == bluebook.name
1135
-
1136
- model = bluebook.read_model(name)
1137
- next unless model && model.group_by.any?
1138
-
1139
- grouped_head = model.aggregate_heads.find { |head| head[:many] }
1140
- next unless grouped_head
1141
-
1142
- rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, grouped_head, asked[:args] || {})
1143
- materialized = rows.map { |state| Runtime::Value.materialize_unwrapped(state) }
1144
- expected = nest_rows(materialized, model.group_by_fields)
1145
- actual = asked[:rows]&.first&.dig(grouped_head[:as])
1146
- next if actual == expected
1147
-
1148
- "#{asked[:query]} #{asked[:args].inspect} answered a #{grouped_head[:as]} grouping that disagrees " \
1149
- "with independently nesting group_by #{model.group_by_fields.inspect} over #{rows.length} " \
1150
- "eligible row(s)"
1151
- end
1152
-
1153
- offenders.empty? || offenders.join("; ")
1154
- end
1155
-
1156
- # `ReadModelInterpreter#nest`, byte for byte: one level of nesting
1157
- # per `group_by` field in declared order, leaf is the row with
1158
- # every grouped field stripped (already spent, as the keys that
1159
- # reached it).
1160
- def nest_rows(rows, fields)
1161
- field, *rest = fields
1162
- rows.group_by { |row| row[field] }.transform_values do |group|
1163
- stripped = group.map { |row| row.reject { |key, _| key == field } }
1164
- rest.empty? ? stripped.first : nest_rows(stripped, rest)
1165
- end
1166
- end
1167
-
1168
- # THE ELIGIBLE ROWS a `count`/`median` head reduces — every
1169
- # instance of the reduced head's own aggregate, FK-matched against
1170
- # the report's root reference (if it has one; a rootless report has
1171
- # none to match) exactly the way `ReadModelInterpreter#reference_fields`
1172
- # finds the matching attribute, then narrowed by the report's own
1173
- # `where` clauses via the SAME `InMemory.holds?` the interpreter's
1174
- # `execute` calls.
1175
- def eligible_rows(bluebook, instances, domain, model, reduced_head, args)
1176
- aggregate = bluebook.aggregate(reduced_head[:aggregate])
1177
- prefix = "#{domain}::#{reduced_head[:aggregate]}#"
1178
- # `id:` MERGED IN, the same `record.to_h` (`@state.merge(id:
1179
- # @id)`) every live head row carries — count/median never read
1180
- # it, but group_by_matches_recompute's own independent nesting
1181
- # does, the same way ReadModelInterpreter#row(record) = record.
1182
- # to_h does for the live path it's checking against.
1183
- rows = instances.filter_map { |key, state| state.merge(id: key.split("#").last) if key.start_with?(prefix) }
1184
-
1185
- if model.reference_target
1186
- reference_id = args[model.reference_name].to_s
1187
- fk_fields = aggregate.attributes.select do |attribute|
1188
- attribute.reference? && attribute.type.target_name == model.reference_target.to_s
1189
- end.map(&:name)
1190
-
1191
- rows = rows.select { |state| fk_fields.any? { |field| state[field].to_s == reference_id } }
1192
- end
1193
-
1194
- rows.select do |state|
1195
- model.wheres.all? do |clause|
1196
- held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, clause.field))
1197
- Ports::Query::InMemory.holds?(clause, held, args)
1198
- end
1199
- end
1200
- end
1201
-
1202
- # `ReadModelInterpreter#median`'s own definition, reproduced byte
1203
- # for byte: odd count → the true middle value, sorted; even count
1204
- # → the average of the two middle values, as a Float; empty → nil,
1205
- # never zero, so a caller cannot mistake "nothing to average" for
1206
- # "averaged to zero."
1207
- def recompute_median(rows, field)
1208
- values = rows.map { |state| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, field)) }
1209
- .compact.sort
1210
- return nil if values.empty?
1211
-
1212
- middle = values.length / 2
1213
- values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
1214
- end
1215
-
1216
192
  # THE STANDARD BATTERY, run over one replayed history — everything
1217
193
  # except determinism, which needs to replay TWICE itself and so
1218
194
  # takes the steps directly rather than a single history.