hecks 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
  15. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
  16. data/lib/hecks/adapters/driven/postgres.rb +25 -14
  17. data/lib/hecks/adapters/driven/prism.rb +11 -0
  18. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  19. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  20. data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
  21. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  22. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  23. data/lib/hecks/adapters/driven.rb +4 -0
  24. data/lib/hecks/adapters.rb +4 -0
  25. data/lib/hecks/behaviors/dsl.rb +7 -0
  26. data/lib/hecks/behaviors/expectations.rb +32 -4
  27. data/lib/hecks/behaviors/rspec.rb +2 -0
  28. data/lib/hecks/behaviors/runner.rb +4 -0
  29. data/lib/hecks/bluebook/aggregate.rb +6 -5
  30. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  31. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  32. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  33. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  34. data/lib/hecks/bluebook/assembly/marks.rb +2 -1
  35. data/lib/hecks/bluebook/assembly.rb +1 -3
  36. data/lib/hecks/bluebook/attribute.rb +6 -0
  37. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  38. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  39. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  40. data/lib/hecks/bluebook/command.rb +6 -5
  41. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  42. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  43. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
  44. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  45. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  46. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  47. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  48. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  49. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  50. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  51. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  52. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  53. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  54. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  55. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  56. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  57. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  58. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  59. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  60. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  61. data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
  62. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  63. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  64. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  65. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  66. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  67. data/lib/hecks/bluebook/entity.rb +5 -3
  68. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  69. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  70. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  71. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  72. data/lib/hecks/bluebook/expression/projection.json +12 -0
  73. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  74. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  75. data/lib/hecks/bluebook/expression.rb +1 -0
  76. data/lib/hecks/bluebook/hexagon.rb +10 -0
  77. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  78. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  79. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  80. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  81. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  82. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  83. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  84. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  85. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  86. data/lib/hecks/bluebook/model_check.rb +119 -82
  87. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  88. data/lib/hecks/bluebook/policy.rb +2 -1
  89. data/lib/hecks/bluebook/process_manager.rb +6 -0
  90. data/lib/hecks/bluebook/project_register.rb +45 -25
  91. data/lib/hecks/bluebook/query.rb +4 -0
  92. data/lib/hecks/bluebook/read_model.rb +2 -2
  93. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  94. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  95. data/lib/hecks/bluebook/translation.rb +3 -0
  96. data/lib/hecks/bluebook/value_object.rb +2 -2
  97. data/lib/hecks/codemod.rb +13 -0
  98. data/lib/hecks/doc/reference.rb +17 -11
  99. data/lib/hecks/facade/cli_runner.rb +2 -2
  100. data/lib/hecks/facade/command_request.rb +11 -11
  101. data/lib/hecks/facade/handle.rb +3 -3
  102. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  103. data/lib/hecks/facade.rb +4 -0
  104. data/lib/hecks/forms/app.rb +10 -6
  105. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  106. data/lib/hecks/forms/field_renderer.rb +14 -9
  107. data/lib/hecks/forms/field_shape.rb +7 -2
  108. data/lib/hecks/forms/html.rb +5 -2
  109. data/lib/hecks/forms/index_renderer.rb +1 -1
  110. data/lib/hecks/forms/page.rb +7 -3
  111. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  112. data/lib/hecks/forms/record_renderer.rb +22 -16
  113. data/lib/hecks/forms.rb +4 -0
  114. data/lib/hecks/fqn.rb +21 -9
  115. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  116. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  117. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  118. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  119. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  120. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  121. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  122. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  123. data/lib/hecks/fuzzing/properties.rb +41 -1065
  124. data/lib/hecks/fuzzing/replay.rb +60 -16
  125. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  126. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  127. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  128. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  129. data/lib/hecks/grammar/evolve.rb +34 -10
  130. data/lib/hecks/grammar/expression_operators.json +124 -0
  131. data/lib/hecks/grammar.rb +15 -5
  132. data/lib/hecks/ir.rb +9 -0
  133. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  134. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  135. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  136. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  138. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  140. data/lib/hecks/literal.rb +18 -8
  141. data/lib/hecks/naming.rb +6 -3
  142. data/lib/hecks/ports/access_control.rb +1 -1
  143. data/lib/hecks/ports/agent.rb +5 -3
  144. data/lib/hecks/ports/authentication.rb +3 -2
  145. data/lib/hecks/ports/authorization.rb +1 -1
  146. data/lib/hecks/ports/clock.rb +1 -1
  147. data/lib/hecks/ports/extraction.rb +5 -1
  148. data/lib/hecks/ports/identity_assignment.rb +1 -1
  149. data/lib/hecks/ports/identity_generation.rb +1 -1
  150. data/lib/hecks/ports/identity_resolution.rb +1 -1
  151. data/lib/hecks/ports/loading.rb +4 -1
  152. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  153. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  154. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  155. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  156. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  157. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  158. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  159. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
  169. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  170. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  172. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  173. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  174. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  175. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  176. data/lib/hecks/ports/persistence.rb +7 -3
  177. data/lib/hecks/ports/projection.rb +13 -5
  178. data/lib/hecks/ports/query/in_memory.rb +8 -2
  179. data/lib/hecks/ports/query.rb +1 -3
  180. data/lib/hecks/ports.rb +2 -0
  181. data/lib/hecks/projections/diagrams.rb +11 -3
  182. data/lib/hecks/projections/ir.rb +2 -0
  183. data/lib/hecks/projections/model/deviations.rb +2 -1
  184. data/lib/hecks/projections/model.rb +15 -10
  185. data/lib/hecks/projections/vocabulary.rb +1 -3
  186. data/lib/hecks/projector/cli_projector.rb +49 -29
  187. data/lib/hecks/projector/docs_projector.rb +21 -12
  188. data/lib/hecks/projector/exporter.rb +10 -1
  189. data/lib/hecks/projector/narrate_projector.rb +83 -25
  190. data/lib/hecks/projector.rb +2 -2
  191. data/lib/hecks/query_ir.rb +60 -36
  192. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  193. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  194. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  195. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  196. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  197. data/lib/hecks/query_specification/common/options.rb +17 -5
  198. data/lib/hecks/query_specification/field_path.rb +4 -4
  199. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  200. data/lib/hecks/router/namespace_installer.rb +22 -10
  201. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  202. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  203. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  204. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  205. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  206. data/lib/hecks/runtime/dispatcher.rb +41 -7
  207. data/lib/hecks/runtime/entity_element.rb +25 -6
  208. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  209. data/lib/hecks/runtime/errors.rb +28 -2
  210. data/lib/hecks/runtime/instance.rb +9 -3
  211. data/lib/hecks/runtime/interpreting.rb +29 -2
  212. data/lib/hecks/runtime/loader.rb +21 -0
  213. data/lib/hecks/runtime/outbox.rb +365 -0
  214. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  215. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  216. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  217. data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
  218. data/lib/hecks/runtime/reference_hop.rb +1 -1
  219. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  220. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  221. data/lib/hecks/runtime/registry/verification.rb +39 -2
  222. data/lib/hecks/runtime/registry.rb +21 -11
  223. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  224. data/lib/hecks/runtime/routing.rb +52 -39
  225. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  226. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  227. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  228. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  229. data/lib/hecks/runtime/value/coercion.rb +207 -48
  230. data/lib/hecks/runtime/value.rb +1 -1
  231. data/lib/hecks/runtime.rb +7 -2
  232. data/lib/hecks/storehouse.rb +15 -8
  233. data/lib/hecks/version.rb +1 -1
  234. data/lib/hecks/vocabulary.rb +1 -0
  235. data/lib/hecks.rb +17 -13
  236. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  237. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  238. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  239. metadata +14 -2
@@ -0,0 +1,251 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module Properties
4
+ # Guard/authorization properties: every given/ensures refusal a run
5
+ # actually raised names a rule the refusing command actually declares,
6
+ # a scope-authorized answer (or its refusal) is correctly worded, and
7
+ # a lifecycle guard's own violation is refused rather than silently
8
+ # admitted.
9
+
10
+ # Holds authorize_scopes_or_refuses, guard_refusals_are_declared, and
11
+ # lifecycle_guard_and_given_violations_are_refused, plus the shared
12
+ # verb-to-declaration lookups (#command_for_verb,
13
+ # #effective_guard_descriptions) each resolves a refusal against.
14
+ module Guards
15
+ # `Query#options`' OTHER HALF — TenantScope.apply's own contract
16
+ # (tenant_scope.rb), independently restated as a property rather
17
+ # than exercised only through whatever the generator happens to
18
+ # try. NOT closed by the generator here on purpose: SafeDepositBox.
19
+ # Rented — the only real corpus query declaring `authorize` at
20
+ # all — declares ZERO attributes of its own, so StepBuilder#args_for
21
+ # always hands it `{}` and TenantScope.apply refuses every
22
+ # generated attempt, unconditionally (confirmed: no successful ask
23
+ # against an authorize-bearing query reaches this property via the
24
+ # standard battery today). Extending the generator to invent a
25
+ # `tenant:` value ran into a separate, real finding along the way —
26
+ # SafeDepositBox is COMPOSITE-identified (`identified_by` is nil
27
+ # for it — Runtime::Identified#derive_identity), so the generator's
28
+ # existing `known_ids` pool (keyed by `aggregate.identified_by ||
29
+ # "id"`) tracks a stray, never-real scalar for it rather than its
30
+ # true `branch_code`+`box_number` pair — a second, narrower
31
+ # generator gap this property does not attempt to fix, since fixing
32
+ # it well enough to trust a generated `tenant:` value would be the
33
+ # heavier, "benefits every future property" path the plan itself
34
+ # names as the alternative. Hand-built fixtures close the real
35
+ # claim directly instead: faster, narrower, and correct either way,
36
+ # since TenantScope.apply's contract is identical regardless of
37
+ # where a `tenant:` arg came from.
38
+ #
39
+ # Two claims, matching TenantScope.apply's own two branches: every
40
+ # SUCCESSFUL answer's own tenant field agrees with the tenant arg
41
+ # given (the WhereClause TenantScope injects is a Symbol reference
42
+ # into args, resolved dynamically — this checks the OUTCOME, not
43
+ # re-deriving that resolution) ; every ask MISSING a required
44
+ # tenant: refuses with the declared wording, never succeeds. A
45
+ # refusal for an unrelated reason with the tenant arg present is
46
+ # not this property's claim either way — skipped, not graded.
47
+ # The three mutually exclusive outcomes ("refused, was it for the
48
+ # declared reason?" / "succeeded without a tenant that was
49
+ # required?" / "succeeded with a tenant, does every row actually
50
+ # agree with it?") map exactly onto TenantScope.apply's own two
51
+ # branches, per the comment above — splitting them into separate
52
+ # methods would mean threading `asked`, `tenant`, `args`, and
53
+ # `declared` out to each one for no gain, since none of the three
54
+ # branches shares logic with the others beyond that shared setup.
55
+ # rubocop:disable-next Metrics/CyclomaticComplexity
56
+ # rubocop:disable-next Metrics/PerceivedComplexity
57
+ def authorize_scopes_or_refuses(history)
58
+ bluebooks = history.fetch(:bluebooks)
59
+
60
+ offenders = history.fetch(:queries).filter_map do |asked|
61
+ next unless asked[:query].is_a?(String) && asked[:query].include?("::")
62
+
63
+ declared = query_for_verb(bluebooks, asked[:query])
64
+ authorization = declared&.authorization
65
+ tenant = authorization&.tenant&.to_sym
66
+ next unless tenant
67
+
68
+ args = asked[:args] || {}
69
+ tenant_given = args.key?(tenant)
70
+
71
+ if asked[:error]
72
+ next if tenant_given
73
+ next if asked[:error].to_s.include?("declares authorize with tenant: #{tenant}")
74
+
75
+ "#{asked[:query]} #{args.inspect} refused with no #{tenant}: given, but not with the declared " \
76
+ "tenant_required wording (#{asked[:error]})"
77
+ elsif !tenant_given
78
+ "#{asked[:query]} #{args.inspect} answered successfully with no #{tenant}: given, but #{declared.name} " \
79
+ "declares authorize with tenant: #{tenant}"
80
+ else
81
+ wanted = args[tenant].to_s
82
+ mismatched = asked[:rows].find do |row|
83
+ Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, tenant)).to_s != wanted
84
+ end
85
+ next unless mismatched
86
+
87
+ "#{asked[:query]} #{args.inspect} answered a row whose #{tenant} disagrees with the given " \
88
+ "#{wanted.inspect}: #{mismatched.inspect}"
89
+ end
90
+ end
91
+
92
+ offenders.empty? || offenders.join("; ")
93
+ end
94
+
95
+ # EVERY GIVEN/ENSURES REFUSAL A RUN ACTUALLY RAISED NAMES A RULE
96
+ # THE COMMAND ACTUALLY DECLARES. `GivenNotMet`/`EnsuresNotMet` both
97
+ # quote their guard's own `description` verbatim
98
+ # (command_rules/admissibility.rb: `"#{command.hecks_name} refused
99
+ # — #{given.description}"`) — the SAME text `behavior.bluebook`'s
100
+ # own `Rule`/Command.Ensure hold as `Rule#description`, so a
101
+ # refusal whose quoted text is not among the refusing command's
102
+ # OWN `guard_descriptions` (Behaviour::Command, both givens and
103
+ # ensures) is either a stale message surviving a renamed rule, a
104
+ # rule firing against the wrong command's own guard set, or the
105
+ # wording drifting out from under the declaration it is supposed
106
+ # to quote — banking's own 128 status givens (customer/account
107
+ # guards, some through a cross-aggregate dereference) are exactly
108
+ # the surface this exists to hold to its word.
109
+ #
110
+ # `kind:` is what tells a guard refusal apart from the FOUR other
111
+ # `RefusalWording` templates sharing the identical "X refused — Y"
112
+ # shape (LifecycleRefused/transition_blocked, both TypeMismatch
113
+ # object-reference templates, Unauthorized/role_mismatch) — see
114
+ # Replay's own comment at the refusal rescue site. Pattern-matching
115
+ # the string alone would confuse a guard's own wording with any of
116
+ # those; the raised class does not.
117
+ GUARD_REFUSAL_KINDS = %w[Hecks::Runtime::GivenNotMet Hecks::Runtime::EnsuresNotMet].freeze
118
+
119
+ def guard_refusals_are_declared(history)
120
+ bluebooks = history.fetch(:bluebooks)
121
+
122
+ offenders = history.fetch(:refusals).filter_map do |refusal|
123
+ next unless GUARD_REFUSAL_KINDS.include?(refusal[:kind])
124
+
125
+ match = refusal[:error].to_s.match(/\A(.+) refused — (.+)\z/)
126
+ next "#{refusal[:verb]} raised #{refusal[:kind]} with unparseable message #{refusal[:error].inspect}" unless match
127
+
128
+ command = command_for_verb(bluebooks, refusal[:verb])
129
+ next "#{refusal[:verb]} raised #{refusal[:kind]}, but no declared command resolves that verb" unless command
130
+
131
+ declared = effective_guard_descriptions(bluebooks, refusal[:verb], command)
132
+ next if declared.include?(match[2])
133
+
134
+ "#{refusal[:verb]} refused — #{match[2].inspect} — but #{command.hecks_name} declares no given " \
135
+ "or ensures with that description (it declares #{declared.inspect})"
136
+ end
137
+
138
+ offenders.empty? || offenders.join("; ")
139
+ end
140
+
141
+ # A DECLARED PROCESS MANAGER'S OWN COMMAND — `command.hecks_name`,
142
+ # or an entity's own if the verb's second component is itself
143
+ # dotted (`Aggregate.Entity.Command`, the same two shapes
144
+ # `Dispatcher#dispatch` itself branches on). Shared by the guard
145
+ # property above and available for anything else that needs to go
146
+ # from a replayed verb back to its declaration.
147
+ #
148
+ # RESOLVED AGAINST `bluebooks` (the FULL map, `history[:bluebooks]`
149
+ # — every loaded domain, keyed by name), never a single assumed
150
+ # bluebook: a verb names its OWN domain (`Naming.split_verb`'s
151
+ # first element), and that domain is not always the one Replay
152
+ # happens to expose as `history[:bluebook]`. A fuzz run against
153
+ # `lib/hecks/grammar` (Expression + Translation, in load
154
+ # order) found this the hard way — every `Translation::Map.Seal`
155
+ # refusal read as "no declared command resolves that verb" purely
156
+ # because `history[:bluebook]` was Expression, not Translation; the
157
+ # refusal was real, this property's own domain resolution was not.
158
+ # A DELEGATING DOOR REFUSES WITH ITS TARGET'S OWN WORDS. `delegates_to`
159
+ # (CommandBuilder#delegates_to_impl) hands the whole dispatch to one
160
+ # entity command, and that command's given is what refuses — raised
161
+ # back through the door, in the door's name (chess: `Game.MoveKnight
162
+ # refused — "it is that color's turn"`, a given Knight.Move declares
163
+ # and MoveKnight, a pure passthrough, never could). Read the door's
164
+ # own guards first, then every delegation target's; an offence is
165
+ # only a description NEITHER declares. Found live mining chess's
166
+ # history: every refused move through a door read as undeclared.
167
+ def effective_guard_descriptions(bluebooks, verb, command)
168
+ own = command.guard_descriptions
169
+ delegated = command.mutations.select { |m| m.op == :delegate }.flat_map do |delegation|
170
+ domain, aggregate_name, = Naming.split_verb(verb)
171
+ target = command_for_verb(bluebooks, "#{domain}::#{aggregate_name}.#{delegation.target}")
172
+ target ? target.guard_descriptions : []
173
+ end
174
+ own + delegated
175
+ end
176
+
177
+ def command_for_verb(bluebooks, verb)
178
+ domain, aggregate_name, command_path = Naming.split_verb(verb)
179
+ return nil unless command_path
180
+
181
+ bluebook = bluebooks[domain]
182
+ return nil unless bluebook
183
+
184
+ aggregate = bluebook.aggregate(aggregate_name)
185
+ return nil unless aggregate
186
+
187
+ if command_path.include?(".")
188
+ entity_name, sub = command_path.split(".", 2)
189
+ entity = aggregate.entities.find { |e| e.hecks_name == entity_name }
190
+ entity&.command(sub)
191
+ else
192
+ aggregate.command(command_path)
193
+ end
194
+ end
195
+
196
+ # `guard_refusals_are_declared`'s OWN OPPOSITE DIRECTION. That
197
+ # property is passive and one-directional — for a refusal that
198
+ # ALREADY HAPPENED, is the quoted text real declared text? It says
199
+ # nothing about a guard that should have refused and silently did
200
+ # not — a call site that stopped calling enforce_givens/enforce_
201
+ # lifecycle_guard would never appear in history[:refusals] at all,
202
+ # invisible to that property by construction.
203
+ #
204
+ # This one calls Admissibility#enforce_givens (which itself folds
205
+ # in #enforce_lifecycle_guard whenever `declaring:` is passed)
206
+ # DIRECTLY, against Replay's own pre-dispatch snapshot
207
+ # (history[:guard_checks], one bounded, additive extension — see
208
+ # that file's own comment at the capture site) — an independent
209
+ # recomputation, not grading production against itself, the same
210
+ # "two engines, compared" shape query_answers_match_reference and
211
+ # the fan-out oracle already establish. `recomputed_refused`
212
+ # (Replay's own call, made live, before this step's real dispatch
213
+ # could mutate anything a cross-aggregate given dereferences) is
214
+ # compared against `actual_refused` (GivenNotMet/LifecycleRefused
215
+ # specifically — Replay's own comment on GUARD_REFUSAL_CLASSES
216
+ # explains why ANY other refusal class, or an outright success,
217
+ # both count as "the guard did not fire," since enforce_givens
218
+ # runs FIRST in DISPATCH_ORDER).
219
+ #
220
+ # Aggregate#preconditions closes for free alongside this — a
221
+ # no-block `given` reference (CommandBuilder#given) pushes the
222
+ # SAME Given struct object `enforce_givens` already iterates
223
+ # command.givens for, so there is no separate runtime path a
224
+ # property could exercise beyond what this already reaches.
225
+ # Entity#preconditions closes the identical way, one level down
226
+ # (ADR 0028) — a piece's own bare `given` reference pushes the
227
+ # SAME Given struct onto ITS OWN referencing command's givens,
228
+ # so LedgerEntry's own Amend/Reverse (banking) already exercise
229
+ # this through the exact mechanism above, no separate path.
230
+ #
231
+ # Real targets: Account.Debit/CloseAccount (`from:` guards),
232
+ # Credit/Debit (the named-once `given("customer is active")`
233
+ # precondition) — FreezeAccount deliberately references the
234
+ # DIFFERENT named precondition `"customer is not closed"` instead
235
+ # (a suspended customer must still be freezable), so it is not a
236
+ # `"customer is active"` example, just the same MECHANISM.
237
+ def lifecycle_guard_and_given_violations_are_refused(history)
238
+ offenders = history.fetch(:guard_checks).filter_map do |check|
239
+ next if check[:recomputed_refused] == check[:actual_refused]
240
+
241
+ "#{check[:verb]} — independently recomputing enforce_givens/enforce_lifecycle_guard against the " \
242
+ "pre-dispatch state says #{check[:recomputed_refused] ? "refused (#{check[:recomputed_kind]})" : 'admitted'}, " \
243
+ "but the real dispatch #{check[:actual_refused] ? "refused (#{check[:actual_kind]})" : 'admitted it'}"
244
+ end
245
+
246
+ offenders.empty? || offenders.join("; ")
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,351 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module Properties
4
+ # Stored-record, saga-rehydration, fan-out, and read-model-aggregation
5
+ # properties: every stored record still satisfies its own declared
6
+ # invariants, a saga rehydrates cleanly from a checkpoint, a policy's
7
+ # own for_each/where fans out exactly once per matching row, and a
8
+ # read model's count/median/group_by answers match an independent
9
+ # recomputation.
10
+
11
+ # Holds stored_records_satisfy_declared_invariants, sagas_rehydrate_cleanly,
12
+ # fanout_dispatches_once_per_matching_row, aggregation_matches_recompute,
13
+ # and group_by_matches_recompute, plus the recomputation helpers
14
+ # (#check_piece_invariants, #eligible_rows, #nest_rows,
15
+ # #recompute_median) each leans on.
16
+ module InvariantsAndAggregation
17
+ # EVERY STORED RECORD STILL SATISFIES ITS OWN AGGREGATE'S DECLARED
18
+ # INVARIANTS — Admissibility#enforce_invariants (command_rules/
19
+ # admissibility.rb) checks these AFTER every command's mutations,
20
+ # BEFORE save, the same point `ensures` is checked. Nothing until
21
+ # now re-checked a record AFTER a whole replay finished, independent
22
+ # of whichever call site was supposed to have refused a violation
23
+ # in the first place — a record failing its own declared invariant
24
+ # here is proof a violating write landed anyway: the call site
25
+ # stopped calling enforce_invariants, or some other path (a
26
+ # translation, a backfill) wrote around it entirely.
27
+ #
28
+ # `history[:instances]` entries are already plain, symbol-keyed
29
+ # state Hashes (Replay.call's own `record.state`) — called against
30
+ # Evaluator.call the SAME way ValueObject::Builder#build already
31
+ # does for a VO's own invariants (value/coercion.rb), no GuardState
32
+ # wrapper needed the way enforce_invariants' own LIVE call uses one
33
+ # (GuardState exists for `parent.`/projected-field dereferencing
34
+ # mid-dispatch; a stored record's own scalar fields need none of
35
+ # that to re-check a same-aggregate invariant against itself).
36
+ #
37
+ # Real target: Account's own `invariant("the balance never goes
38
+ # negative") { balance.cents >= 0 }`.
39
+ #
40
+ # Entity#invariants (round 7) closes here too, not for free —
41
+ # `stored_records_satisfy_declared_invariants` only ever checked
42
+ # the AGGREGATE's own flat state; a piece's own invariant is
43
+ # checked against every ELEMENT of a `list_of` field, a genuinely
44
+ # different walk `check_piece_invariants` below makes,
45
+ # independently of `Admissibility#check_entity_invariants` (the
46
+ # live enforcement path this property exists to catch drifting
47
+ # from) — same reasoning `stored_records_satisfy_declared_
48
+ # invariants`' own top-level check already applies one level up.
49
+ #
50
+ # Real target: SafeDepositBox's own Visit — `invariant("a written
51
+ # note is not blank") { !note || !note.text.to_s.empty? }`.
52
+ def stored_records_satisfy_declared_invariants(history)
53
+ bluebooks = history.fetch(:bluebooks)
54
+
55
+ offenders = history.fetch(:instances).filter_map do |key, state|
56
+ domain_name = key.split("::").first
57
+ aggregate_name = key.split("::").last.split("#").first
58
+ bluebook = bluebooks[domain_name]
59
+ aggregate = bluebook&.aggregate(aggregate_name)
60
+ next unless aggregate
61
+
62
+ violated = aggregate.invariants.find do |invariant|
63
+ !Bluebook::Expression::Evaluator.call(invariant.canonical, state)
64
+ end
65
+ next "#{key} violates #{aggregate_name}'s own declared invariant #{violated.description.inspect}" if violated
66
+
67
+ check_piece_invariants(aggregate, state, key)
68
+ end
69
+
70
+ offenders.empty? || offenders.join("; ")
71
+ end
72
+
73
+ # A PIECE'S OWN INVARIANT, checked against every element a
74
+ # `list_of` field holds — the SAME lookup `Admissibility#
75
+ # check_entity_invariants` makes (`owner.attributes.find { |a|
76
+ # a.list? && a.type.to_s == entity.hecks_name }`), independently
77
+ # reapplied here against a STORED record's own plain Hash state
78
+ # rather than a live `Instance`.
79
+ def check_piece_invariants(owner_construct, owner_state, key)
80
+ owner_construct.entities.each do |entity|
81
+ next if entity.invariants.empty?
82
+
83
+ list_attr = owner_construct.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
84
+ next unless list_attr
85
+
86
+ Array(owner_state[list_attr.name]).each do |element|
87
+ violated = entity.invariants.find do |invariant|
88
+ !Bluebook::Expression::Evaluator.call(invariant.canonical, element)
89
+ end
90
+ if violated
91
+ return "#{key}'s own #{entity.hecks_name} violates its declared invariant " \
92
+ "#{violated.description.inspect}"
93
+ end
94
+
95
+ nested = check_piece_invariants(entity, element, key)
96
+ return nested if nested
97
+ end
98
+ end
99
+ nil
100
+ end
101
+
102
+ # A SAGA INSTANCE'S OWN CHECKPOINT SURVIVES BEING WRITTEN AND READ
103
+ # BACK — the durability contract `SagaInterpreter#checkpoint` makes
104
+ # (`state:` plus a `deep_copy`d `memory:`, handed to whatever
105
+ # adapter answers `save_saga`) and `Registry#rehydrate_sagas!`
106
+ # promises to restore on the next boot (`each_saga` yielding
107
+ # `[pm, correlation, state, memory]` back into `saga_instances`).
108
+ # `Replay` captures the LIVE store already materialised the same
109
+ # way `checkpoint` itself does (`Value.materialize`, not raw
110
+ # `Runtime::Value`s — see its own comment); this property pushes
111
+ # that captured memory through the SAME `JSON.generate` then
112
+ # `JSON.parse(symbolize_names: true)` round-trip `checkpoint`'s own
113
+ # `deep_copy` performs (mirrored here rather than called — a
114
+ # private instance method with no registry to hand it) and checks
115
+ # it comes back byte-identical. A memory holding anything that
116
+ # round-trip cannot carry faithfully — a bare Symbol leaf, a
117
+ # non-JSON type a future field introduces — is corruption the
118
+ # durable path would introduce on a REAL restart, caught here
119
+ # without needing one.
120
+ #
121
+ # `declares_state?` (Behaviour::ProcessManager) is the other half:
122
+ # a live or rehydrated instance sitting in a state the procedure
123
+ # never declares is the saga-durability twin of
124
+ # `lifecycle_values_are_declared` above.
125
+ def sagas_rehydrate_cleanly(history)
126
+ bluebook = history.fetch(:bluebook)
127
+ process_managers = bluebook.process_managers.to_h { |pm| [pm.name, pm] }
128
+
129
+ offenders = history.fetch(:saga_instances).flat_map do |pm_name, conversations|
130
+ pm = process_managers[pm_name]
131
+
132
+ conversations.filter_map do |correlation, instance|
133
+ problems = []
134
+
135
+ problems << "holds state #{instance[:state].inspect}, which #{pm_name} never declares" \
136
+ if pm && !pm.declares_state?(instance[:state])
137
+
138
+ rehydrated = JSON.parse(JSON.generate(instance[:memory]), symbolize_names: true)
139
+ if rehydrated != instance[:memory]
140
+ problems << "memory does not survive its own checkpoint round-trip " \
141
+ "(checkpointed #{instance[:memory].inspect}, rehydrated #{rehydrated.inspect})"
142
+ end
143
+
144
+ next if problems.empty?
145
+
146
+ "#{pm_name}##{correlation.inspect}: #{problems.join(' and ')}"
147
+ end
148
+ end
149
+
150
+ offenders.empty? || offenders.join("; ")
151
+ end
152
+
153
+ # A `for_each` POLICY DISPATCHES EXACTLY ONCE PER ROW ITS DECLARED
154
+ # QUERY ANSWERS — never once for the triggering event regardless of
155
+ # row count, never skipping a matched row, never firing on a row a
156
+ # concurrent mutation only made match AFTER the fact. `Replay`
157
+ # computes the expected row-id set INDEPENDENTLY, at the same
158
+ # instant the real dispatch runs (`Replay.expected_fan_out_rows`,
159
+ # the query oracle's own shape aimed at fan-out: two engines
160
+ # compared, never one graded against itself), and records it
161
+ # beside what the reaction log actually shows. `expected_row_ids`
162
+ # is `nil`, not `[]`, when `policy.where` did not hold — no
163
+ # dispatch is the claim then, not "dispatched to zero rows," and a
164
+ # policy that dispatched anyway despite a failing guard is as real
165
+ # a finding as a row it skipped.
166
+ def fanout_dispatches_once_per_matching_row(history)
167
+ offenders = history.fetch(:fan_outs).filter_map do |finding|
168
+ expected = finding[:expected_row_ids]
169
+ actual = finding[:actual_row_ids].sort
170
+
171
+ if expected.nil?
172
+ next if actual.empty?
173
+
174
+ next "#{finding[:policy]} on #{finding[:on]}: where did not hold, but dispatched to #{actual.inspect}"
175
+ end
176
+
177
+ next if actual == expected
178
+
179
+ "#{finding[:policy]} on #{finding[:on]}: for_each answered #{expected.inspect}, " \
180
+ "but the reaction log shows dispatches to #{actual.inspect}"
181
+ end
182
+
183
+ offenders.empty? || offenders.join("; ")
184
+ end
185
+
186
+ # A `count`/`median` REPORT'S REDUCED SCALAR MATCHES THE SAME
187
+ # REDUCTION DONE INDEPENDENTLY, over the SAME eligible rows —
188
+ # `ReadModelInterpreter#project`'s own FK-join (root first, then
189
+ # each many-side head matched against it) and `#median` (odd →
190
+ # the true middle, even → the average of the two middles as a
191
+ # Float, empty → `nil`; `count` is the filtered length, empty →
192
+ # `0`), reproduced here in plain Ruby against `history[:instances]`
193
+ # rather than a live registry — `FieldPath.dig` +
194
+ # `Ports::Query::InMemory.comparable`/`.holds?` are the SAME two
195
+ # calls the interpreter itself makes to read a field and judge a
196
+ # `where`, called here rather than re-derived, so this oracle
197
+ # cannot drift from what "read a field" or "a clause holds" mean
198
+ # without the interpreter drifting the identical way.
199
+ #
200
+ # Only a report whose `:query` is answered by the SAME bluebook
201
+ # `history[:bluebook]` carries (the bare `Domain.report_name`
202
+ # form, `domain == bluebook.name`) is checked — the same "only
203
+ # what we have the grammar for" scope `lifecycle_values_are_declared`
204
+ # already takes for a multi-domain replay.
205
+ # See the comment above: a closed chain of eligibility guards
206
+ # ("only a report answered by this bluebook, only a count/median
207
+ # report, only one with a reduced many-side head") each gating the
208
+ # next, ending in one independent recomputation compared against
209
+ # the live answer. The guards are what make this oracle SCOPED
210
+ # correctly, not incidental complexity — narrower than "every
211
+ # branch reads as its own precondition."
212
+ # rubocop:disable-next Metrics/CyclomaticComplexity
213
+ # rubocop:disable-next Metrics/PerceivedComplexity
214
+ def aggregation_matches_recompute(history)
215
+ bluebook = history.fetch(:bluebook)
216
+
217
+ offenders = history.fetch(:queries).filter_map do |asked|
218
+ next if asked[:error]
219
+
220
+ domain, name = asked[:query].to_s.split(".", 2)
221
+ next unless name && domain == bluebook.name
222
+
223
+ model = bluebook.read_model(name)
224
+ next unless model && (model.count? || model.median_field)
225
+
226
+ reduced_head = model.aggregate_heads.find { |head| head[:many] }
227
+ next unless reduced_head
228
+
229
+ rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, reduced_head, asked[:args] || {})
230
+ expected = model.count? ? rows.length : recompute_median(rows, model.median_field)
231
+ actual = asked[:rows]&.first&.dig(reduced_head[:as])
232
+ next if actual == expected
233
+
234
+ "#{asked[:query]} #{asked[:args].inspect} answered #{actual.inspect} for #{reduced_head[:as]}, " \
235
+ "but recomputing independently from #{rows.length} eligible row(s) gives #{expected.inspect}"
236
+ end
237
+
238
+ offenders.empty? || offenders.join("; ")
239
+ end
240
+
241
+ # `aggregation_matches_recompute`'s own shape, extended from
242
+ # reducing a many-side head to a scalar (count/median) to NESTING
243
+ # it — `ReadModelInterpreter#group_by_target`/`#nest`, reproduced
244
+ # here in plain Ruby against `history[:instances]` the same way
245
+ # `eligible_rows` already reproduces the FK-join and `where`
246
+ # narrowing count/median share. `Value.materialize_unwrapped` is
247
+ # the SAME call `#project` makes before nesting (a single-field
248
+ # value object recurses to its bare scalar — a real grouping key
249
+ # has to BE one) — called here rather than re-derived, so this
250
+ # oracle cannot drift from what "the group key" means without the
251
+ # interpreter drifting the identical way.
252
+ #
253
+ # Real target: AccountsByKind (`group_by :kind, :number`,
254
+ # rootless — always generator-eligible with `{}` args).
255
+ # Same closed eligibility-guard chain as aggregation_matches_
256
+ # recompute just above (see its own comment) — group_by in place
257
+ # of count/median, nest_rows in place of recompute_median.
258
+ # rubocop:disable-next Metrics/CyclomaticComplexity
259
+ # rubocop:disable-next Metrics/PerceivedComplexity
260
+ def group_by_matches_recompute(history)
261
+ bluebook = history.fetch(:bluebook)
262
+
263
+ offenders = history.fetch(:queries).filter_map do |asked|
264
+ next if asked[:error]
265
+
266
+ domain, name = asked[:query].to_s.split(".", 2)
267
+ next unless name && domain == bluebook.name
268
+
269
+ model = bluebook.read_model(name)
270
+ next unless model&.group_by&.any?
271
+
272
+ grouped_head = model.aggregate_heads.find { |head| head[:many] }
273
+ next unless grouped_head
274
+
275
+ rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, grouped_head, asked[:args] || {})
276
+ materialized = rows.map { |state| Runtime::Value.materialize_unwrapped(state) }
277
+ expected = nest_rows(materialized, model.group_by_fields)
278
+ actual = asked[:rows]&.first&.dig(grouped_head[:as])
279
+ next if actual == expected
280
+
281
+ "#{asked[:query]} #{asked[:args].inspect} answered a #{grouped_head[:as]} grouping that disagrees " \
282
+ "with independently nesting group_by #{model.group_by_fields.inspect} over #{rows.length} " \
283
+ "eligible row(s)"
284
+ end
285
+
286
+ offenders.empty? || offenders.join("; ")
287
+ end
288
+
289
+ # `ReadModelInterpreter#nest`, byte for byte: one level of nesting
290
+ # per `group_by` field in declared order, leaf is the row with
291
+ # every grouped field stripped (already spent, as the keys that
292
+ # reached it).
293
+ def nest_rows(rows, fields)
294
+ field, *rest = fields
295
+ rows.group_by { |row| row[field] }.transform_values do |group|
296
+ stripped = group.map { |row| row.reject { |key, _| key == field } }
297
+ rest.empty? ? stripped.first : nest_rows(stripped, rest)
298
+ end
299
+ end
300
+
301
+ # THE ELIGIBLE ROWS a `count`/`median` head reduces — every
302
+ # instance of the reduced head's own aggregate, FK-matched against
303
+ # the report's root reference (if it has one; a rootless report has
304
+ # none to match) exactly the way `ReadModelInterpreter#reference_fields`
305
+ # finds the matching attribute, then narrowed by the report's own
306
+ # `where` clauses via the SAME `InMemory.holds?` the interpreter's
307
+ # `execute` calls.
308
+ def eligible_rows(bluebook, instances, domain, model, reduced_head, args)
309
+ aggregate = bluebook.aggregate(reduced_head[:aggregate])
310
+ prefix = "#{domain}::#{reduced_head[:aggregate]}#"
311
+ # `id:` MERGED IN, the same `record.to_h` (`@state.merge(id:
312
+ # @id)`) every live head row carries — count/median never read
313
+ # it, but group_by_matches_recompute's own independent nesting
314
+ # does, the same way ReadModelInterpreter#row(record) = record.
315
+ # to_h does for the live path it's checking against.
316
+ rows = instances.filter_map { |key, state| state.merge(id: key.split("#").last) if key.start_with?(prefix) }
317
+
318
+ if model.reference_target
319
+ reference_id = args[model.reference_name].to_s
320
+ fk_fields = aggregate.attributes.select do |attribute|
321
+ attribute.reference? && attribute.type.target_name == model.reference_target.to_s
322
+ end.map(&:name)
323
+
324
+ rows = rows.select { |state| fk_fields.any? { |field| state[field].to_s == reference_id } }
325
+ end
326
+
327
+ rows.select do |state|
328
+ model.wheres.all? do |clause|
329
+ held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, clause.field))
330
+ Ports::Query::InMemory.holds?(clause, held, args)
331
+ end
332
+ end
333
+ end
334
+
335
+ # `ReadModelInterpreter#median`'s own definition, reproduced byte
336
+ # for byte: odd count → the true middle value, sorted; even count
337
+ # → the average of the two middle values, as a Float; empty → nil,
338
+ # never zero, so a caller cannot mistake "nothing to average" for
339
+ # "averaged to zero."
340
+ def recompute_median(rows, field)
341
+ values = rows.map { |state| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, field)) }
342
+ .compact.sort
343
+ return nil if values.empty?
344
+
345
+ middle = values.length / 2
346
+ values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end