hecks 2.6.0 → 2.8.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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -1,930 +0,0 @@
1
- require "fileutils"
2
- require "tmpdir"
3
- require_relative "isolated_boot"
4
- require_relative "self_consistency"
5
- require_relative "../query_specification/common/comparators"
6
- require_relative "../query_specification/common/where_clause"
7
- require_relative "../query_specification/field_path"
8
- require_relative "../ports/query/in_memory"
9
-
10
- module Hecks
11
- module Fuzzing
12
- # A step list, replayed in-process against a fresh boot — the same
13
- # copy-to-tmp-and-reset preamble SequenceGenerator#call uses, and the
14
- # same observable surface bin/run prints (instances, events,
15
- # refusals, reactions, sagas, queries), but returned as data rather
16
- # than JSON on stdout.
17
- #
18
- # Not what SequenceGenerator itself dispatches through while
19
- # generating — that inline execution feeds the picker's own
20
- # known_ids tracking and stays exactly as it is. This exists for
21
- # everything else that needs "given a step list, boot fresh and tell
22
- # me what happened": bin/fuzz recomputing a shrink candidate's true
23
- # event count (removing a step changes what the sequence actually
24
- # produces, so a shrunk candidate cannot reuse the original claim),
25
- # and the declared-property checks in properties.rb. Both want it
26
- # in-process — a property or a shrink-candidate check is a question
27
- # this boot can answer itself, and paying for a subprocess and a
28
- # second boot to ask it would be ~100x the cost for nothing.
29
- #
30
- # Refuses the same way SequenceGenerator's own safe_call does: a
31
- # DOMAIN_REFUSAL or an EvaluationError is the domain declining a
32
- # step, recorded and not fatal to the replay. Anything else
33
- # propagates — a step that breaks the interpreter is a defect, not
34
- # an observation to fold quietly into the history.
35
- module Replay
36
- module_function
37
-
38
- # The ad hoc filter's own comparator roster — read directly from
39
- # QuerySpecification::Common::COMPARATORS (the same nine names
40
- # Vocabulary::QueryComparator declares), never re-typed. A
41
- # declared bluebook query never sees an `op:` outside this set —
42
- # `admits: "Vocabulary::QueryComparator"` refuses one at declare
43
- # time — but a `"filter"`-shaped query step (below) has no
44
- # declare-time gate at all, so this method gates it here instead.
45
- #
46
- # Not rust/src/kernel/query_comparators.rs's own ground truth —
47
- # that hand-maintained Rust enum is missing `none_in_state` (the
48
- # 9th comparator, added after the enum was written) and has
49
- # already drifted; do not treat it as authoritative until item #9
50
- # of the whole-project table-unification survey closes that gap.
51
- FILTER_COMPARATORS = Hecks::QuerySpecification::Common::COMPARATORS.map(&:to_s).freeze
52
-
53
- # The two classes `#enforce_givens`/`#enforce_lifecycle_guard`
54
- # themselves ever raise — see Admissibility's own doc comment,
55
- # `command_rules/admissibility.rb`. Any other DOMAIN_REFUSAL a step
56
- # raises (TypeMismatch, EnsuresNotMet, InvariantViolation, ...)
57
- # proves the guard itself did not fire, since it runs first in
58
- # DISPATCH_ORDER.
59
- GUARD_REFUSAL_CLASSES = [Runtime::GivenNotMet, Runtime::LifecycleRefused].freeze
60
-
61
- # One tightly ordered loop over steps, with several "oracle" snapshots
62
- # (reaction_mark, fan_out_snapshot, guard_check, mutation_trace) that
63
- # must be taken at very specific points relative to dispatch — see
64
- # fan_out_snapshot's own comment above for the real bug this exact
65
- # before/after ordering fixes. Splitting this into smaller methods
66
- # would mean threading five-plus oracle-state locals across method
67
- # boundaries as parameters/return values, and
68
- # would let a future editor silently reorder a snapshot relative to
69
- # `dispatch` without any single method looking wrong — the ordering
70
- # invariant is only visible with the whole sequence in one place.
71
- # rubocop:disable-next Metrics/AbcSize
72
- # rubocop:disable-next Metrics/CyclomaticComplexity
73
- # rubocop:disable-next Metrics/MethodLength
74
- # rubocop:disable-next Metrics/PerceivedComplexity
75
- # `self_consistency:` — off by default, same "existing callers see no
76
- # change" contract `adapter:` already has. `bin/qa_sweep` is the one
77
- # real caller that opts in (gated by `QualityControlDials::
78
- # SELF_CONSISTENCY_CHECKS`/`--self-consistency`): `bin/fuzz`,
79
- # `Properties.check`'s own callers, and every existing spec keep
80
- # calling this with no second axis of comparison at all, exactly as
81
- # before. Computed here, not by a caller reading `runtime` back out
82
- # afterward — `runtime` and the whole `IsolatedBoot` tmp directory
83
- # go out of scope the moment this method returns (see this file's
84
- # own header), so `Hecks::Fuzzing::SelfConsistency.check` has to run
85
- # while both are still alive, against the exact same repositories
86
- # this replay's own dispatch loop just wrote to.
87
- #
88
- # `database:`/`schema:` — only meaningful, and required, for
89
- # `adapter: :postgres_era` — see `IsolatedBoot#rebind_to_postgres_era!`'s
90
- # own header for why that one mode takes caller-owned connection
91
- # identity rather than a shared default the way `:postgres` does.
92
- # Forwarded straight through, unchanged, exactly like `adapter:`
93
- # itself already was.
94
- #
95
- # @param domain_path [String] filesystem path to the domain directory to replay
96
- # @param steps [Array<Hash>] the step list to dispatch, any-keyed (normalized to
97
- # String keys internally)
98
- # @param adapter [Symbol] persistence adapter to boot the copy with
99
- # @param database [String, nil] PostgresEra database name; required, only meaningful
100
- # when `adapter: :postgres_era`
101
- # @param schema [String, nil] PostgresEra schema name; required, only meaningful
102
- # when `adapter: :postgres_era`
103
- # @param self_consistency [Boolean] whether to run `SelfConsistency.check` against
104
- # this replay's own runtime before returning
105
- # @return [Hash] the replay history: `:instances`, `:events`, `:refusals`,
106
- # `:reactions`, `:sagas`, `:saga_instances`, `:queries`, `:dry_runs`,
107
- # `:dry_run_traces`, `:fan_outs`, `:guard_checks`, `:mutation_traces`,
108
- # `:outbox_traces`, `:saga_dispatches`, `:policy_dispatches`, `:bluebook`,
109
- # `:bluebooks`, and (when `self_consistency:` is true) `:self_consistency`
110
- def call(domain_path, steps, adapter: :memory, database: nil, schema: nil, self_consistency: false)
111
- # See isolated_boot.rb's own header: resets data/ and rebinds
112
- # persistence to the chosen adapter (Memory by default), since a
113
- # Postgres-bound domain's real store lives outside the copied
114
- # directory and cannot be reached by resetting data/ alone.
115
- IsolatedBoot.call(domain_path, adapter: adapter, database: database, schema: schema) do |copy|
116
- runtime = Hecks.boot(copy)
117
-
118
- refusals = []
119
- queries = []
120
- dry_runs = []
121
- dry_run_traces = []
122
- fan_outs = []
123
- guard_checks = []
124
- mutation_traces = []
125
- outbox_traces = []
126
-
127
- # Every aggregate a `for_each` could ever query, resolved once —
128
- # `[domain, aggregate_name]` pairs, gleaned from every loaded
129
- # bluebook's own fanning-out policies. Empty for every domain
130
- # with no `for_each` at all (every example this corpus ships
131
- # today), so the snapshot below costs nothing until a domain
132
- # actually declares one.
133
- fan_out_targets = runtime.registry.bluebooks.each_with_object({}) do |(domain, bluebook), targets|
134
- bluebook.policies.select(&:fans_out?).each do |policy|
135
- query_domain, aggregate_name, = policy.for_each_route(domain)
136
- targets[[query_domain, aggregate_name]] ||= runtime.registry.bluebook(query_domain)&.aggregate(aggregate_name)
137
- end
138
- end
139
-
140
- steps.each do |step|
141
- step = step.transform_keys(&:to_s)
142
- args = (step["args"] || {}).transform_keys(&:to_sym)
143
-
144
- if (question = step["query"])
145
- # **The ad hoc, single-comparator filter** — a "query" step whose
146
- # own value is a Hash, not a name: `{aggregate:, field:, op:,
147
- # value:}`, the same wire shape kernel/cli.rs's new object-
148
- # form "query" step reads on the Rust side (that file's own
149
- # header explains why this shape exists at all: it bypasses
150
- # the bluebook query DSL entirely, so it needs no generated
151
- # per-domain codegen to prove for real). Answered here by
152
- # calling `Ports::Query::InMemory` directly — the real
153
- # production comparator engine, not a second, hand-rewritten
154
- # copy of it — against the raw repository, never through
155
- # `runtime.query`, which only ever resolves a named, declared
156
- # ask.
157
- if question.is_a?(Hash)
158
- begin
159
- rows = run_filter(runtime, question)
160
- queries << { query: question, rows: rows, instances_at: snapshot_instances(runtime) }
161
- rescue StandardError => e
162
- refusals << { verb: filter_label(question), error: e.message, kind: refusal_kind(e) }
163
- end
164
- next
165
- end
166
-
167
- # **The query oracle** — two independent engines, each run and
168
- # caught on its own, never a single shared `begin`/`rescue`
169
- # wrapping both calls. A shared begin/rescue meant `runtime.
170
- # query` raising (native refuses) short-circuited before
171
- # `runtime.reference_query` ever ran at all — the entry
172
- # recorded only `error:`, with no `reference_rows` and no
173
- # record of what the reference interpreter would have
174
- # answered — and `runtime.reference_query` raising instead
175
- # (reference refuses, native already succeeded) landed in the
176
- # same rescue, discarding the native `rows` this begin block
177
- # had already computed and recording the whole ask as an
178
- # ordinary refusal. Either way, "one engine refused and the
179
- # other did not" — a real divergence, exactly the shape a
180
- # differential oracle exists to catch — read as "refused,
181
- # nothing to compare" and vanished. Each engine's own
182
- # success/failure is captured here independently instead, so
183
- # `Properties.query_answers_match_reference` can tell "both
184
- # refused" (fine) apart from "one refused, the other did not"
185
- # (a finding) rather than having every refusal-shaped run
186
- # skipped uniformly.
187
- native_rows = native_error = nil
188
- begin
189
- native_rows = runtime.query(question, **args)
190
- rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
191
- native_error = e
192
- end
193
-
194
- # Read-model asks (bare domain form, no "::") have no
195
- # reference twin at all — never attempted, not "attempted and
196
- # agreed."
197
- has_reference = question.include?("::")
198
- reference_rows = reference_error = nil
199
- if has_reference
200
- begin
201
- reference_rows = runtime.reference_query(question, **args)
202
- rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
203
- reference_error = e
204
- end
205
- end
206
-
207
- entry = { query: question, args: args, rows: native_rows, instances_at: snapshot_instances(runtime) }
208
- entry[:error] = native_error.message if native_error
209
- if has_reference
210
- entry[:reference_rows] = reference_rows
211
- entry[:reference_error] = reference_error.message if reference_error
212
- end
213
- queries << entry
214
-
215
- refusals << { verb: question, error: native_error.message, kind: refusal_kind(native_error) } if native_error
216
- next
217
- end
218
-
219
- # `{"dry_run": verb, "args": …}` — `Dispatcher#dry_run?`: the command
220
- # evaluated hypothetically, nothing saved or emitted, no reaction.
221
- # Recorded, never a refusal: a refused dry run is an answer.
222
- #
223
- # `dry_runs` stays exactly `{verb:, ok:, error?:}` — the same
224
- # shape it always had, and the same shape `kernel/cli.rs`'s own
225
- # `dry_run` answers (`{"verb", "ok"}` or `{"verb", "ok": false,
226
- # "error"}`, that function's own doc comment) — `spec/rust_
227
- # conformance_spec.rb` compares this array against the compiled
228
- # binary's own verbatim, so it can never carry a key Rust's own
229
- # answer does not. The role-gated binding (`as_step_caller`,
230
- # the same `role:`/`actor_id:` a real dispatch gets below) still
231
- # applies to the dry-run call itself — only what gets recorded
232
- # about it is unchanged.
233
- #
234
- # `dry_run_traces` — a separate, parallel array (same order,
235
- # not merged into `dry_runs` above) carrying `before:`/`after:`
236
- # snapshots of the whole observable store (every instance, the
237
- # event count) on either side of the hypothetical call, so
238
- # `Properties.dry_runs_leave_no_trace` can hold `Dispatcher
239
- # #dry_run?`'s own contract to the store rather than trusting
240
- # it. Ruby's own oracle data — Rust has nothing to compare it
241
- # against, so it stays out of the compared surface entirely.
242
- if (hypothetical = step["dry_run"])
243
- before = { instances: snapshot_instances(runtime), events: runtime.events.size }
244
- entry = { verb: hypothetical }
245
- begin
246
- as_step_caller(step) { runtime.dry_run?(hypothetical, **args) }
247
- entry[:ok] = true
248
- rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
249
- entry.merge!(ok: false, error: e.message)
250
- end
251
- after = { instances: snapshot_instances(runtime), events: runtime.events.size }
252
- dry_runs << entry
253
- dry_run_traces << entry.merge(before: before, after: after)
254
- next
255
- end
256
-
257
- begin
258
- # **The fan-out oracle's own low-water mark** — taken before
259
- # dispatch, so any reaction this one step's own announced
260
- # events produce (`reaction_log` grows in place, the same
261
- # Array `runtime.reactions` already exposes) can be sliced
262
- # out after, and matched against an independent recomputation
263
- # of what a `for_each` policy should have fanned out over —
264
- # the query oracle's own shape (two engines, compared, never
265
- # one graded against itself), aimed at fan-out instead of a
266
- # named ask.
267
- reaction_mark = runtime.reactions.size
268
-
269
- # **The outbox oracle's own low-water marks** — taken before
270
- # dispatch, same idiom as `reaction_mark` right above:
271
- # `saga_log_mark` slices `runtime.sagas` (a single flat
272
- # array, safe to index into directly) the identical way
273
- # `reaction_mark` already slices `runtime.reactions`.
274
- # `outbox_before_ids` is a set of delivery_ids, not a
275
- # size — `runtime.outbox.rows` concatenates every bound
276
- # repository's own array in a fixed per-store order
277
- # (`Outbox::Relay#rows`, `stores.flat_map`), so a row a
278
- # different step's dispatch enqueues into an
279
- # earlier-iterated store would land in the middle of
280
- # that concatenated list, not at its tail — a plain
281
- # "grew from N to M, take the tail" slice (the shape
282
- # `reaction_mark`/`saga_log_mark` both get away with,
283
- # since `reaction_log`/`saga_log` are each already one
284
- # flat array irrespective of domain) would silently miss
285
- # or misattribute rows the moment more than one
286
- # repository has an outbox. `delivery_id` is unique per
287
- # store by construction (`Row#to_h`'s own header;
288
- # `hecks_outbox`'s `UNIQUE` column in the Postgres/Sqlite
289
- # DDL, `outbox_enqueue`'s own de-dup check in Memory), so
290
- # membership in this set is exactly "existed before this
291
- # step's own dispatch ran."
292
- saga_log_mark = runtime.sagas.size
293
- outbox_before_ids = runtime.outbox.rows.map(&:delivery_id)
294
-
295
- # The snapshot a `for_each` query would have seen — taken
296
- # before this step's own dispatch, not after. The real
297
- # `deliver_for_each` runs its query synchronously, inside
298
- # this same dispatch, before this call even returns — so an
299
- # oracle that re-reads the live repository after `dispatch`
300
- # answers sees whatever the fan-out's own dispatched
301
- # commands already mutated (an Account a `Review` leg just
302
- # moved out of "open," say), not what the query actually
303
- # matched. A measured bug, not a hypothetical one — this
304
- # exact ordering is what `spec/fuzzing/fan_out_spec.rb`'s
305
- # own adversarial run against a live `for_each` policy
306
- # caught the first time this shipped without it.
307
- fan_out_snapshot = fan_out_targets.each_with_object({}) do |((fdomain, faggregate_name), aggregate), snap|
308
- next unless aggregate
309
-
310
- snap[[fdomain, faggregate_name]] =
311
- runtime.registry.repository(fdomain, aggregate).all.to_h { |record| [record.id, record.state.dup] }
312
- end
313
-
314
- # **The guard oracle's own pre-dispatch read** — same idiom,
315
- # same placement, same reason as fan_out_snapshot right
316
- # above: `Admissibility#enforce_givens` (which itself calls
317
- # `#enforce_lifecycle_guard` when `declaring:` is passed)
318
- # is called a second time here, independently, against the
319
- # record exactly as CommandInterpreter#hydrate's own acting
320
- # branch would find it (`repository.find(id).dup` — the
321
- # identical three-tier id fallback, Identity.of/.from,
322
- # reproduced read-only) — before this step's real dispatch
323
- # can mutate anything a cross-aggregate given dereferences
324
- # (`customer.status`). A pure predicate read, side-effect
325
- # free, so calling it twice changes nothing this step
326
- # itself observes.
327
- guard_check = build_guard_check(runtime, step["verb"], args)
328
-
329
- # **The mutation oracle's own pre-dispatch read** — same
330
- # idiom again: an entity-dispatched command's own
331
- # `append`/`remove`/`multiply`/`clamp` mutations (S17's
332
- # fixture, spec/fixtures/entity_list_mutations, now a real
333
- # bootable domain) act on the entity's own attributes, so
334
- # the element addressed by this step's own identity args
335
- # is snapshotted before dispatch, materialized to plain
336
- # data — `nil` for anything out of scope (an aggregate-
337
- # level command, an entity command with no mutations at
338
- # all, or one whose identity args don't resolve).
339
- mutation_trace = build_mutation_trace(runtime, step["verb"], args)
340
-
341
- # `role:`/`actor_id:` — optional per-step keys, absent on every
342
- # one of the 231 existing `spec/corpus/*.json` steps (their own
343
- # unwrapped `runtime.dispatch` call, unchanged, so nothing
344
- # already pinned changes behavior). Binds the same ambient
345
- # caller `refuse_role_mismatch` reads (`Hecks.as_caller`,
346
- # `Runtime::Caller.as`) for exactly the one dispatch this
347
- # step makes, then unbinds — mirrors `Caller.as`'s own
348
- # `ensure`-restore, so back-to-back steps with different (or
349
- # no) `role:` never leak into each other. `actor_id:` is the
350
- # sibling `kernel/cli.rs` already read (its own comment on
351
- # the key): with it, a Governance-attached domain runs the
352
- # real `holds_role?` lookup instead of the string fallback.
353
- result = as_step_caller(step) { runtime.dispatch_flat(step["verb"], args) }
354
-
355
- fan_outs.concat(fan_out_findings(runtime, fan_out_snapshot, result.events, runtime.reactions[reaction_mark..]))
356
-
357
- # **The outbox oracle's own capture** — every outbox row this
358
- # step's own dispatch newly wrote (across every bound
359
- # repository, including any a reaction cascade touched —
360
- # `outbox_before_ids` was taken before `dispatch`, which
361
- # is the same call that runs the whole cascade
362
- # synchronously, `reenter` included), paired with the
363
- # `reaction_log`/`saga_log` rows that same dispatch
364
- # produced. Skipped entirely when empty — a step whose
365
- # own aggregate has no outbox enqueues nothing here, and
366
- # there is nothing for `Properties.outbox_rows_match_
367
- # reactions` to check for it (its own reactions, if any,
368
- # went through the direct, pre-outbox path instead).
369
- outbox_new_rows = runtime.outbox.rows.reject { |row| outbox_before_ids.include?(row.delivery_id) }
370
- if outbox_new_rows.any?
371
- outbox_traces << { verb: step["verb"], rows: outbox_new_rows.map(&:to_h),
372
- reactions: runtime.reactions[reaction_mark..].dup,
373
- sagas: runtime.sagas[saga_log_mark..].dup }
374
- end
375
-
376
- guard_checks << guard_check.merge(actual_refused: false, actual_kind: nil) if guard_check
377
- # After — only on success ; a refused step mutated nothing,
378
- # so there is no "after" to compare (and #build_mutation_
379
- # trace already skipped anything with no mutations to
380
- # trace in the first place).
381
- mutation_traces << mutation_trace.merge(after: read_mutation_after(runtime, mutation_trace)) if mutation_trace
382
- rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
383
- # `kind:` — the raised class, not re-derived from the message.
384
- # `GivenNotMet`/`EnsuresNotMet` share their exact wording
385
- # ("<command> refused — <description>") with four other
386
- # refusal templates (Vocabulary's own LifecycleRefused/
387
- # TypeMismatch/Unauthorized entries) — a property that told
388
- # a guard refusal apart by pattern-matching the string alone
389
- # would misread every one of those as a guard, or a guard as
390
- # one of those. The class is unambiguous where the string
391
- # is not.
392
- refusals << { verb: step["verb"], error: e.message, kind: refusal_kind(e) }
393
- # Only a refusal raised by the guard itself counts here —
394
- # measured, not assumed: a step whose args were simply
395
- # incomplete (AbsentArgument, from normalize_args — which
396
- # runs before enforce_givens in DISPATCH_ORDER) never
397
- # reached the guard at all, and this oracle's own first
398
- # live run against real generated pizzas data caught
399
- # exactly that case as a false positive (a malformed-args
400
- # step the generator deliberately produces, `amount:`
401
- # dropped entirely) before this comment existed. Whether a
402
- # refusal from a stage after enforce_givens (TypeMismatch
403
- # on a mutation, EnsuresNotMet, InvariantViolation) proves
404
- # the guard passed can't be told apart from a same-shaped
405
- # refusal from a stage before it by class alone (TypeMismatch
406
- # can come from either), so anything that isn't one of the
407
- # two guard classes is left out of guard_checks entirely —
408
- # inconclusive, not a claimed pass.
409
- if guard_check && GUARD_REFUSAL_CLASSES.include?(e.class)
410
- guard_checks << guard_check.merge(actual_refused: true,
411
- actual_kind: e.class.name)
412
- end
413
- end
414
- end
415
-
416
- instances = snapshot_instances(runtime)
417
-
418
- events = runtime.events.map do |event|
419
- { name: event.name, aggregate: event.aggregate, id: event.id, payload: event.payload }
420
- end
421
-
422
- # The live process-manager store, materialised to inert data —
423
- # `{ pm_name => { correlation => { state:, memory: } } }`, the
424
- # same shape SagaInterpreter#checkpoint hands its persistence
425
- # adapter (state plus a `Value.materialize`d memory, which is
426
- # exactly what `deep_copy` there serialises). Captured here
427
- # because Replay returns the history, not the runtime, and the
428
- # runtime goes out of scope with the boot — so the saga-durability
429
- # property (Properties.sagas_rehydrate_cleanly) reads this rather
430
- # than reaching into a store the Memory rebind leaves as the
431
- # no-op NULL_SAGA_STORE. Materialised, not raw, so the history
432
- # stays plain data and the round-trip check sees exactly the
433
- # bytes a real adapter would have persisted.
434
- saga_instances = runtime.registry.saga_instances.each_with_object({}) do |(pm_name, conversations), out|
435
- out[pm_name] = conversations.each_with_object({}) do |(correlation, instance), rows|
436
- rows[correlation] = { state: instance[:state], memory: Runtime::Value.materialize(instance[:memory]) }
437
- end
438
- end
439
-
440
- # The booted chapter rides along — the boot already happened, and
441
- # properties.rb's lifecycle/saga checks need the declared IR
442
- # (state sets, handler graphs) beside the history it produced.
443
- # Free: no second boot, just the object the first one already
444
- # built.
445
- #
446
- # `bluebook:` (singular) stays the first-loaded chapter — every
447
- # existing property scopes itself to "only what we have the
448
- # grammar for" against exactly this one, deliberately (see
449
- # lifecycle_values_are_declared's own comment). `bluebooks:`
450
- # (plural) is the full map, keyed by domain name — a domain
451
- # under fuzz commonly composes more than one bluebook (banking
452
- # alone loads Banking + Governance + Identity), and a refusal
453
- # or an event can legitimately come from any of them, not only
454
- # whichever one happened to load first. A property that needs
455
- # to resolve a verb back to its own declaring bluebook — not
456
- # "the" bluebook — reads this instead.
457
- history = { instances: instances, events: events, refusals: refusals,
458
- reactions: runtime.reactions, sagas: runtime.sagas, saga_instances: saga_instances,
459
- queries: queries, dry_runs: dry_runs, dry_run_traces: dry_run_traces,
460
- fan_outs: fan_outs, guard_checks: guard_checks,
461
- mutation_traces: mutation_traces, outbox_traces: outbox_traces,
462
- saga_dispatches: runtime.saga_dispatches, policy_dispatches: runtime.policy_dispatches,
463
- bluebook: runtime.registry.bluebooks.values.first,
464
- bluebooks: runtime.registry.bluebooks.dup }
465
-
466
- # `runtime` is still live here — this is the one and only place
467
- # it is. See `SelfConsistency`'s own header for why this needs
468
- # to happen now, against the same registry/repositories this
469
- # replay's own dispatch loop just populated, not a second boot.
470
- history[:self_consistency] = SelfConsistency.check(runtime, history) if self_consistency
471
-
472
- history
473
- end
474
- end
475
-
476
- # Runs the block with this step's `role:`/`actor_id:` bound as the
477
- # ambient caller, if it declares one.
478
- #
479
- # A step with no `role:` dispatches exactly as every corpus step
480
- # always has — bare, no caller bound at all (`Caller.current` nil,
481
- # so `refuse_role_mismatch` returns before checking anything).
482
- #
483
- # @param step [Hash] a String-keyed step hash, read for `"role"`/`"actor_id"`
484
- # @yield the step's own dispatch or dry-run call
485
- # @return [Object] whatever the block returns
486
- def as_step_caller(step, &)
487
- return yield unless step["role"]
488
-
489
- Hecks.as_caller(role: step["role"], actor_id: step["actor_id"], &)
490
- end
491
-
492
- # The guard oracle's own resolution — "which record, if any, is
493
- # this step about, and would enforce_givens/enforce_lifecycle_guard
494
- # have refused it against that record's pre-dispatch state" —
495
- # reproduced read-only from already-public pieces
496
- # (Naming.split_verb, registry.bluebook/.aggregate/.command,
497
- # Runtime::Identity.of/.from, repository.find), the exact same
498
- # three-tier fallback CommandInterpreter#hydrate's own acting
499
- # branch uses, minus its creating/duplicate-checking logic (a
500
- # creating command has no pre-existing record to snapshot, and
501
- # every real target this closes — Debit/CloseAccount/Credit/
502
- # FreezeAccount — already acts on one).
503
- #
504
- # `nil` for anything out of scope: an entity/port verb (a dotted
505
- # command_name — EntityInterpreter's own enforce_givens call,
506
- # `parent:`-shaped, is a different call signature this does not
507
- # reproduce), a creating command, an unresolvable id, or a command
508
- # that declares neither `givens` nor `from` at all (nothing to
509
- # check — logging a guaranteed, tautological pass would be noise,
510
- # not a finding, the same reason `aggregation_matches_recompute`
511
- # skips a read model with no count/median declared).
512
- #
513
- # Never lets a resolution surprise (a malformed verb, a dangling
514
- # reference) become the step's own real dispatch outcome — this
515
- # is a separate, best-effort read, not part of the step's own
516
- # control flow.
517
- # Every persisted record, keyed the way `query_eligible_rows`/
518
- # `#eligible_rows` (properties.rb) already expect. Called once per
519
- # query step (see `call`, above), not only once at the very end,
520
- # because a query asked at step 1 of a script whose later steps go on
521
- # to create more records needs to be checked, by every property that
522
- # independently recomputes "the eligible rows," against the state as
523
- # of that query — not the final snapshot after the whole replay.
524
- # Found live:
525
- # `Banking.accounts_by_kind`, asked as literally the first step of a
526
- # 3-step script, correctly answered against zero accounts (none
527
- # existed yet) while `group_by_matches_recompute`'s own independent
528
- # recompute claimed "1 eligible row" — the one account the script's
529
- # later two steps went on to create. Each query step now carries
530
- # its own `instances_at:` snapshot, taken at the moment it ran, so
531
- # every property that recomputes against "the eligible rows" reads
532
- # the state as that query actually saw it, not a shared final one.
533
- #
534
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
535
- # to read every persisted record from
536
- # @return [Hash{String => Hash}] every persisted record's state, keyed by
537
- # `"Domain::Aggregate#id"`
538
- def snapshot_instances(runtime)
539
- instances = {}
540
- runtime.registry.bluebooks.each do |domain_name, bluebook|
541
- bluebook.aggregates.each do |aggregate|
542
- runtime.registry.repository(domain_name, aggregate).all.each do |record|
543
- instances["#{domain_name}::#{aggregate.name}##{record.id}"] = record.state
544
- end
545
- end
546
- end
547
- instances
548
- end
549
-
550
- # One early-return chain resolving "which record, if any" (see the
551
- # long comment above), feeding a two-stage guard replay
552
- # (enforce_givens then admissible_transition) that must run in that
553
- # order to mirror the real DISPATCH_ORDER. Splitting the resolution
554
- # out would still require passing domain_name/aggregate_name/
555
- # aggregate/command/record back in, for no gain — every early
556
- # return already reads as "nothing to check" at its own site.
557
- # rubocop:disable-next Metrics/AbcSize
558
- # rubocop:disable-next Metrics/CyclomaticComplexity
559
- # rubocop:disable-next Metrics/PerceivedComplexity
560
- #
561
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
562
- # to resolve the step's own verb and record against
563
- # @param verb [String] the step's own command verb
564
- # @param args [Hash{Symbol => Object}] the step's own args, symbol-keyed
565
- # @return [Hash, nil] `{verb:, domain:, aggregate:, command:, id:, recomputed_refused:,
566
- # recomputed_kind:}` if there was a record and a guard to check; nil for anything
567
- # out of scope (an entity/port verb, a creating command, an unresolvable id, or a
568
- # command with nothing to check)
569
- def build_guard_check(runtime, verb, args)
570
- domain_name, aggregate_name, command_name = Naming.split_verb(verb)
571
- return nil unless command_name && !command_name.include?(".")
572
-
573
- aggregate = runtime.registry.bluebook(domain_name)&.aggregate(aggregate_name)
574
- command = aggregate&.command(command_name)
575
- return nil unless aggregate && command && !command.creates?
576
-
577
- # Nothing to check, genuinely — not "nothing THIS reproduces yet".
578
- # A transition-only guard (no per-command `from:`, no `given`,
579
- # only an aggregate `lifecycle do transition ... end` block
580
- # naming this command — `Admit`/`Reject`'s own shape) still
581
- # counts as something to check now that `admissible_transition`
582
- # is reproduced below; skipping it here would just move the
583
- # exact gap that call was added to close one line earlier.
584
- has_transition = aggregate.lifecycle&.transitions_for(command.hecks_name)&.any?
585
- return nil if command.givens.empty? && !command.from && !has_transition
586
-
587
- reference_key = command.references.to_s.empty? ? nil : Naming.reference_key(command.references)
588
- id = Runtime::Identity.of(aggregate, args) ||
589
- Runtime::Identity.from(aggregate, args, :id) ||
590
- (reference_key && Runtime::Identity.from(aggregate, args, reference_key))
591
- return nil unless id
592
-
593
- record = runtime.registry.repository(domain_name, aggregate).find(id)
594
- return nil unless record
595
-
596
- rules = Runtime::CommandRules.new(runtime.registry)
597
- recomputed_kind = begin
598
- rules.enforce_givens(record.dup, command, args, domain: domain_name, declaring: aggregate)
599
-
600
- # A second, separate DISPATCH_ORDER step — `enforce_givens`
601
- # (just above) only ever checks a per-command `from:` clause
602
- # (its own trailing `enforce_lifecycle_guard(declaring, ...)
603
- # if declaring` call) — the aggregate's own `lifecycle do
604
- # transition "X" => Y, from: Z end` block is a wholly separate
605
- # method (`admissible_transition`), called as its own later
606
- # DISPATCH_ORDER step (`:enforce_givens` then
607
- # `:admissible_transition` — Vocabulary.symbols
608
- # ("AggregateDispatchOrder")), not reached from inside
609
- # `enforce_givens` at all. Missing this call meant a command
610
- # declared with no per-command `from:` of its own — every real
611
- # transition-guarded command in this corpus, `Admit`/`Reject`
612
- # included — always recomputed "admitted" no matter the
613
- # record's actual state, because the one check that would
614
- # have refused it was never run. Found live: `Expression::
615
- # Expression.Admit`, fuzzed against `lib/hecks/grammar`
616
- # (a domain the property's own hand-verification — Banking,
617
- # Pizzas — never happened to exercise a transition-guarded,
618
- # no-per-command-`from:` command against). Called only when
619
- # `enforce_givens` didn't already refuse, mirroring the real
620
- # pipeline's own "first refusal wins" order exactly.
621
- rules.admissible_transition(aggregate, command, record.dup)
622
- nil
623
- rescue *GUARD_REFUSAL_CLASSES => e
624
- e.class.name
625
- end
626
-
627
- { verb: verb, domain: domain_name, aggregate: aggregate_name, command: command_name, id: id,
628
- recomputed_refused: !recomputed_kind.nil?, recomputed_kind: recomputed_kind }
629
- rescue StandardError
630
- nil
631
- end
632
-
633
- # The mutation oracle's own pre-dispatch read — scoped, on
634
- # purpose, to entity-dispatched commands only (a dotted
635
- # command_name): the one place `append`/`remove`/`multiply`/
636
- # `clamp` are known to act on an entity's own attributes
637
- # (spec/fixtures/entity_list_mutations' own TaggedList — `tags`
638
- # a value-object list, `count` a VO-typed scalar), never on
639
- # another nested entity list — so this never needs to reproduce
640
- # `MutationApplier#entity_element`'s own auto-mint/collision logic
641
- # (item 1's own fix) at all. An aggregate-level command whose own
642
- # mutation appends an entity (`Board.AddList`, `SafeDepositBox.
643
- # LogVisit`) is a different, already-covered case — item 1's own
644
- # collision property, not this one.
645
- #
646
- # `nil` for anything out of scope: an aggregate-level command, an
647
- # entity command with no mutations at all, or one whose identity
648
- # args (parent or element) don't resolve.
649
- # Same shape as build_guard_check just above: one early-return chain
650
- # resolving the entity/element this step's args address (see the
651
- # comment above), each step depending on the previous one's
652
- # resolved value. Threading those locals out to smaller methods
653
- # would cost more than the cop gains.
654
- # rubocop:disable-next Metrics/AbcSize
655
- # rubocop:disable-next Metrics/CyclomaticComplexity
656
- # rubocop:disable-next Metrics/PerceivedComplexity
657
- #
658
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
659
- # to resolve the step's own verb and record against
660
- # @param verb [String] the step's own command verb
661
- # @param args [Hash{Symbol => Object}] the step's own args, symbol-keyed
662
- # @return [Hash, nil] `{verb:, domain:, aggregate:, command:, parent_id:, list_attr:,
663
- # element_wants:, before:, args:}` if this is an entity-dispatched command with
664
- # mutations acting on a resolvable element; nil for anything out of scope
665
- def build_mutation_trace(runtime, verb, args)
666
- domain_name, aggregate_name, command_name = Naming.split_verb(verb)
667
- return nil unless command_name&.include?(".")
668
-
669
- aggregate = runtime.registry.bluebook(domain_name)&.aggregate(aggregate_name)
670
- return nil unless aggregate
671
-
672
- entity_name, entity_command_name = command_name.split(".", 2)
673
- entity = aggregate.entities.find { |candidate| candidate.hecks_name == entity_name }
674
- command = entity&.command(entity_command_name)
675
- return nil unless command&.mutations&.any?
676
-
677
- reference_key = command.references.to_s.empty? ? nil : Naming.reference_key(command.references)
678
- parent_id = Runtime::Identity.of(aggregate, args) ||
679
- Runtime::Identity.from(aggregate, args, :id) ||
680
- (reference_key && Runtime::Identity.from(aggregate, args, reference_key))
681
- return nil unless parent_id
682
-
683
- record = runtime.registry.repository(domain_name, aggregate).find(parent_id)
684
- return nil unless record
685
-
686
- list_attr = aggregate.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
687
- return nil unless list_attr
688
-
689
- wants = entity.identity_paths.map do |path|
690
- head = path.to_s.split(".").first.to_sym
691
- raw = args[head]
692
- return nil if raw.nil?
693
-
694
- [head, Runtime::Value.for_attribute(aggregate, entity.attribute(head), raw)]
695
- end
696
-
697
- element = Array(record.state[list_attr.name]).find { |el| wants.all? { |head, want| el[head] == want } }
698
- return nil unless element
699
-
700
- { verb: verb, domain: domain_name, aggregate: aggregate_name, command: command_name,
701
- parent_id: parent_id, list_attr: list_attr.name, element_wants: wants,
702
- before: Runtime::Value.materialize(element), args: args }
703
- rescue StandardError
704
- nil
705
- end
706
-
707
- # Re-reads the same entity element, after dispatch, for comparison against
708
- # `trace[:before]`.
709
- #
710
- # The same element, re-located, after dispatch — by identity, not
711
- # position (an append could have changed the array's own length
712
- # or order relative to it). `nil` if it somehow vanished (not
713
- # expected for any op this fixture declares — none of them
714
- # remove the acted-on element itself — but a property comparing
715
- # against `nil` fails loudly rather than crashing this replay).
716
- #
717
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
718
- # dispatch just ran against
719
- # @param trace [Hash] the mutation trace `build_mutation_trace` built for this step,
720
- # read for `:domain`, `:aggregate`, `:parent_id`, `:list_attr`, `:element_wants`
721
- # @return [Hash, nil] the element's materialized state after dispatch, or nil if
722
- # the parent record, its aggregate, or the element itself can no longer be found
723
- def read_mutation_after(runtime, trace)
724
- aggregate = runtime.registry.bluebook(trace[:domain])&.aggregate(trace[:aggregate])
725
- return nil unless aggregate
726
-
727
- record = runtime.registry.repository(trace[:domain], aggregate).find(trace[:parent_id])
728
- return nil unless record
729
-
730
- element = Array(record.state[trace[:list_attr]]).find do |el|
731
- trace[:element_wants].all? { |head, want| el[head] == want }
732
- end
733
- element && Runtime::Value.materialize(element)
734
- rescue StandardError
735
- nil
736
- end
737
-
738
- # The fan-out oracle — one finding per (event, for_each policy) this
739
- # step's own announced events could have triggered, independent of
740
- # `PolicyInterpreter#deliver_for_each`: the same `where` evaluator
741
- # every given/ensures already runs through, but the query answered
742
- # by `Ports::Query::InMemory.holds?` directly against the live
743
- # repository (`Replay.run_filter`'s own idiom), never by calling
744
- # `QueryInterpreter` — sharing that call would make this oracle
745
- # blind to exactly the code the fan-out feature adds.
746
- #
747
- # `expected_row_ids` is `nil` when `where` did not hold — no dispatch
748
- # is the claim, not "dispatched to zero rows," so a property
749
- # comparing this against the reaction log needs to tell the two
750
- # apart. Recomputed once per event, not once per policy-and-event,
751
- # because a `Chapter` de-duplicates on nothing this loop cannot
752
- # cheaply repeat.
753
- #
754
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
755
- # @param snapshot [Hash{Array(String, String) => Hash}] the pre-dispatch `for_each`
756
- # query snapshot, keyed by `[query_domain, aggregate_name]`
757
- # @param announced [Array<Runtime::Event>] the events this step's own dispatch produced
758
- # @param reactions_since [Array<Hash>] the reaction-log rows this step's own
759
- # dispatch appended
760
- # @return [Array<Hash>] one `{policy:, on:, expected_row_ids:, actual_row_ids:}`
761
- # finding per (event, fanning-out policy) pair
762
- def fan_out_findings(runtime, snapshot, announced, reactions_since)
763
- announced.each_with_object([]) do |event, findings|
764
- # `event.aggregate` is domain-qualified ("Banking::Account" —
765
- # see command_rules/emission.rb's own Event.new) — the same
766
- # source `PolicyInterpreter#policies_for` reads, split the
767
- # same two ways: `Naming.demodulise` for the emitting
768
- # aggregate's bare name, plain `split("::")` for the domain.
769
- domain = event.aggregate.to_s.split("::").first
770
- bluebook = runtime.registry.bluebook(domain)
771
- next unless bluebook
772
-
773
- emitting = Naming.demodulise(event.aggregate)
774
-
775
- bluebook.policies.each do |policy|
776
- next unless policy.fans_out? && policy.event_name == event.name
777
- next unless policy.event_qualifier.nil? || policy.event_qualifier == emitting
778
-
779
- findings << fan_out_finding(runtime, snapshot, policy, event, domain, reactions_since)
780
- end
781
- end
782
- end
783
-
784
- # Builds one fan-out finding, comparing one `for_each` policy's independently
785
- # recomputed expected rows against what actually reacted for one event.
786
- #
787
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
788
- # @param snapshot [Hash{Array(String, String) => Hash}] the pre-dispatch `for_each`
789
- # query snapshot, keyed by `[query_domain, aggregate_name]`
790
- # @param policy [Bluebook::Policy] the fanning-out policy this event could trigger
791
- # @param event [Runtime::Event] the announced event being checked
792
- # @param domain [String] the emitting event's own domain name
793
- # @param reactions_since [Array<Hash>] the reaction-log rows this step's own
794
- # dispatch appended
795
- # @return [Hash{Symbol => Object}] `{policy: String, on: String,
796
- # expected_row_ids: Array<String>, nil, actual_row_ids: Array}`
797
- def fan_out_finding(runtime, snapshot, policy, event, domain, reactions_since)
798
- payload = event.payload.transform_keys(&:to_sym)
799
- held = policy.where.to_s.empty? ||
800
- Bluebook::Expression::Evaluator.call(policy.where, {}, payload)
801
-
802
- expected = held ? expected_fan_out_rows(runtime, snapshot, policy, domain, payload) : nil
803
-
804
- actual = reactions_since.select { |r| r[:policy] == policy.name && r[:on] == event.name }
805
- .filter_map { |r| r[:for_row] }
806
-
807
- { policy: policy.name, on: event.name, expected_row_ids: expected, actual_row_ids: actual }
808
- end
809
-
810
- # The independent recomputation — `policy.for_each`'s declared query,
811
- # answered against the pre-dispatch snapshot (see the snapshot's
812
- # own comment at its capture site: the real fan-out's query runs
813
- # synchronously, before its own dispatched commands can mutate
814
- # anything the query would have matched, so this has to read the
815
- # same "before" state or it grades the wrong moment). Comparators
816
- # are `Ports::Query::InMemory.holds?`, never `QueryInterpreter` —
817
- # sharing that call would make this oracle blind to exactly the
818
- # code the fan-out feature adds. A `Symbol` where-value binds to
819
- # the triggering event's own payload (the same binding
820
- # `OpenForCustomer`'s `customer_id: :customer_id` relies on); a
821
- # literal is compared as declared.
822
- #
823
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
824
- # to resolve the target query against
825
- # @param snapshot [Hash{Array(String, String) => Hash}] the pre-dispatch `for_each`
826
- # query snapshot, keyed by `[query_domain, aggregate_name]`
827
- # @param policy [Bluebook::Policy] the fanning-out policy whose `for_each` route
828
- # names the target query
829
- # @param domain [String] the emitting event's own domain name, resolved through
830
- # `policy.for_each_route`
831
- # @param payload [Hash] the triggering event's own payload, symbol-keyed; binds any
832
- # `Symbol` where-value
833
- # @return [Array<String>] matching row ids, as strings, sorted; `[]` if the target
834
- # query cannot be resolved
835
- def expected_fan_out_rows(runtime, snapshot, policy, domain, payload)
836
- query_domain, aggregate_name, query_name = policy.for_each_route(domain)
837
- aggregate = runtime.registry.bluebook(query_domain)&.aggregate(aggregate_name)
838
- query = aggregate&.query(query_name)
839
- return [] unless query
840
-
841
- rows = snapshot[[query_domain, aggregate_name]] || {}
842
- matched = rows.select do |_id, state|
843
- query.wheres.all? do |clause|
844
- held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, clause.field))
845
- Ports::Query::InMemory.holds?(clause, held, payload)
846
- end
847
- end
848
-
849
- matched.keys.map(&:to_s).sort
850
- end
851
-
852
- # Names the outcome class a recorded refusal row should carry.
853
- #
854
- # The outcome class a recorded refusal row names (C8.2/C8.3,
855
- # docs/semantics/bluebook-semantics.md): a domain refusal is its own
856
- # class; an evaluation fault — the language refusing to interpret a
857
- # broken rule or input — is `"Fault"`, the same word the Rust kernel
858
- # emits (`Refusal::Fault`), never a refusal class and never a raw
859
- # Ruby exception name.
860
- #
861
- # @param error [StandardError] the raised error a step's refusal was rescued as
862
- # @return [String] `"Fault"` for an evaluation fault, otherwise `error`'s own class name
863
- def refusal_kind(error)
864
- error.is_a?(Bluebook::Expression::EvaluationError) ? "Fault" : error.class.name
865
- end
866
-
867
- # Answers one ad hoc filter step for real — the mirror image of
868
- # kernel/cli.rs's own `run_filter`, deliberately calling the exact
869
- # same production module that method's Rust port stands in for
870
- # (`Ports::Query::InMemory`, lib/hecks/ports/query/in_memory.rb)
871
- # rather than re-deriving comparator behavior by hand. `field` walks
872
- # through `QuerySpecification::FieldPath.dig` (the same reading a
873
- # declared where-clause gets), `comparable`/`holds?` are the same
874
- # two calls `InMemory.execute` itself makes per candidate record —
875
- # this is that method's own filter/select step, inlined, because
876
- # there is no declared `Query` object here to hand `execute` (an ad
877
- # hoc filter has no `order_by`/`limit`/`offset` at all, so nothing
878
- # about `execute`'s own ordering/paging logic even applies).
879
- # Sorted by id ascending regardless — `Ports::Query::Ordering`'s own
880
- # header explains why an ask with no declared order still needs
881
- # this tier ("the identity tier is what makes an ask total").
882
- #
883
- # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
884
- # to filter against
885
- # @param filter [Hash] the ad hoc filter step's own Hash value: String-keyed
886
- # `"aggregate"`, `"field"`, `"op"`, `"value"`
887
- # @return [Array<Hash>] matching records, sorted by id ascending, each `{id:}`
888
- # merged with the record's own state
889
- # @raise [Bluebook::Expression::EvaluationError] if `op` names no known comparator,
890
- # or `"aggregate"` names no loaded aggregate
891
- def run_filter(runtime, filter)
892
- aggregate_ref = filter["aggregate"].to_s
893
- field = filter["field"].to_s
894
- op = filter["op"].to_s
895
- value = filter["value"]
896
-
897
- # A malformed ad-hoc ask is a fault (C8.3), not a bare RuntimeError.
898
- unless FILTER_COMPARATORS.include?(op)
899
- raise Bluebook::Expression::EvaluationError, "unknown query comparator #{op.inspect}"
900
- end
901
-
902
- domain_name, aggregate_name = aggregate_ref.split("::", 2)
903
- aggregate = runtime.registry.bluebook(domain_name)&.aggregate(aggregate_name)
904
- raise Bluebook::Expression::EvaluationError, "unknown aggregate #{aggregate_ref.inspect}" unless aggregate
905
-
906
- clause = QuerySpecification::Common::WhereClause.new(field: field, op: op, value: value)
907
- records = runtime.registry.repository(domain_name, aggregate).all
908
- matched = records.select do |record|
909
- held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(record, field))
910
- Ports::Query::InMemory.holds?(clause, held, {})
911
- end
912
-
913
- matched.sort_by { |record| record.id.to_s }.map { |record| { id: record.id }.merge(record.state) }
914
- end
915
-
916
- # Builds the `refusals` entry's own "verb" column for a refused ad hoc filter.
917
- #
918
- # There is no real verb to report (a filter step carries none), so this
919
- # builds the same descriptive label kernel/cli.rs's own `filter_label`
920
- # builds from the same three raw fields, tolerant of any of them being
921
- # missing (Ruby's own nil-to-"" interpolation) the same way that Rust
922
- # port is.
923
- #
924
- # @param filter [Hash] the ad hoc filter step's own Hash value, read for
925
- # `"aggregate"`, `"field"`, `"op"`
926
- # @return [String] a descriptive label such as `"filter Banking::Account.status eq"`
927
- def filter_label(filter) = "filter #{filter['aggregate']}.#{filter['field']} #{filter['op']}"
928
- end
929
- end
930
- end