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,399 +0,0 @@
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
- #
53
- # @param history [Hash] a replayed history as returned by `Replay.call`
54
- # @return [true, String] true if every stored record satisfies its own
55
- # (and its own entities') declared invariants; otherwise a message
56
- # naming the record and the invariant it violates
57
- def stored_records_satisfy_declared_invariants(history)
58
- bluebooks = history.fetch(:bluebooks)
59
-
60
- offenders = history.fetch(:instances).filter_map do |key, state|
61
- domain_name = key.split("::").first
62
- aggregate_name = key.split("::").last.split("#").first
63
- bluebook = bluebooks[domain_name]
64
- aggregate = bluebook&.aggregate(aggregate_name)
65
- next unless aggregate
66
-
67
- violated = aggregate.invariants.find do |invariant|
68
- !Bluebook::Expression::Evaluator.call(invariant.canonical, state)
69
- end
70
- next "#{key} violates #{aggregate_name}'s own declared invariant #{violated.description.inspect}" if violated
71
-
72
- check_piece_invariants(aggregate, state, key)
73
- end
74
-
75
- offenders.empty? || offenders.join("; ")
76
- end
77
-
78
- # A piece's own invariant, checked against every element a
79
- # `list_of` field holds — the same lookup `Admissibility#
80
- # check_entity_invariants` makes (`owner.attributes.find { |a|
81
- # a.list? && a.type.to_s == entity.hecks_name }`), independently
82
- # reapplied here against a stored record's own plain Hash state
83
- # rather than a live `Instance`.
84
- # @param owner_construct [Bluebook::Aggregate, Bluebook::Entity] the
85
- # construct whose own `list_of` entities to check
86
- # @param owner_state [Hash] `owner_construct`'s own stored state
87
- # @param key [String] the top-level record key, for the message
88
- # @return [String, nil] a message naming the first violating piece found,
89
- # at any nesting depth; `nil` if every piece satisfies its invariants
90
- def check_piece_invariants(owner_construct, owner_state, key)
91
- owner_construct.entities.each do |entity|
92
- next if entity.invariants.empty?
93
-
94
- list_attr = owner_construct.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
95
- next unless list_attr
96
-
97
- Array(owner_state[list_attr.name]).each do |element|
98
- violated = entity.invariants.find do |invariant|
99
- !Bluebook::Expression::Evaluator.call(invariant.canonical, element)
100
- end
101
- if violated
102
- return "#{key}'s own #{entity.hecks_name} violates its declared invariant " \
103
- "#{violated.description.inspect}"
104
- end
105
-
106
- nested = check_piece_invariants(entity, element, key)
107
- return nested if nested
108
- end
109
- end
110
- nil
111
- end
112
-
113
- # A saga instance's own checkpoint survives being written and read
114
- # back — the durability contract `SagaInterpreter#checkpoint` makes
115
- # (`state:` plus a `deep_copy`d `memory:`, handed to whatever
116
- # adapter answers `save_saga`) and `Registry#rehydrate_sagas!`
117
- # promises to restore on the next boot (`each_saga` yielding
118
- # `[pm, correlation, state, memory]` back into `saga_instances`).
119
- # `Replay` captures the live store already materialised the same
120
- # way `checkpoint` itself does (`Value.materialize`, not raw
121
- # `Runtime::Value`s — see its own comment); this property pushes
122
- # that captured memory through the same `JSON.generate` then
123
- # `JSON.parse(symbolize_names: true)` round-trip `checkpoint`'s own
124
- # `deep_copy` performs (mirrored here rather than called — a
125
- # private instance method with no registry to hand it) and checks
126
- # it comes back byte-identical. A memory holding anything that
127
- # round-trip cannot carry faithfully — a bare Symbol leaf, a
128
- # non-JSON type a future field introduces — is corruption the
129
- # durable path would introduce on a real restart, caught here
130
- # without needing one.
131
- #
132
- # `declares_state?` (Behaviour::ProcessManager) is the other half:
133
- # a live or rehydrated instance sitting in a state the procedure
134
- # never declares is the saga-durability twin of
135
- # `lifecycle_values_are_declared` above.
136
- # @param history [Hash] a replayed history as returned by `Replay.call`
137
- # @return [true, String] true if every saga instance holds a declared
138
- # state and its own memory survives a checkpoint round-trip; otherwise a
139
- # message naming the process manager, correlation, and problem
140
- def sagas_rehydrate_cleanly(history)
141
- bluebook = history.fetch(:bluebook)
142
- process_managers = bluebook.process_managers.to_h { |pm| [pm.name, pm] }
143
-
144
- offenders = history.fetch(:saga_instances).flat_map do |pm_name, conversations|
145
- pm = process_managers[pm_name]
146
-
147
- conversations.filter_map do |correlation, instance|
148
- problems = []
149
-
150
- problems << "holds state #{instance[:state].inspect}, which #{pm_name} never declares" \
151
- if pm && !pm.declares_state?(instance[:state])
152
-
153
- rehydrated = JSON.parse(JSON.generate(instance[:memory]), symbolize_names: true)
154
- if rehydrated != instance[:memory]
155
- problems << "memory does not survive its own checkpoint round-trip " \
156
- "(checkpointed #{instance[:memory].inspect}, rehydrated #{rehydrated.inspect})"
157
- end
158
-
159
- next if problems.empty?
160
-
161
- "#{pm_name}##{correlation.inspect}: #{problems.join(' and ')}"
162
- end
163
- end
164
-
165
- offenders.empty? || offenders.join("; ")
166
- end
167
-
168
- # A `for_each` policy dispatches exactly once per row its declared
169
- # query answers — never once for the triggering event regardless of
170
- # row count, never skipping a matched row, never firing on a row a
171
- # concurrent mutation only made match after the fact. `Replay`
172
- # computes the expected row-id set independently, at the same
173
- # instant the real dispatch runs (`Replay.expected_fan_out_rows`,
174
- # the query oracle's own shape aimed at fan-out: two engines
175
- # compared, never one graded against itself), and records it
176
- # beside what the reaction log actually shows. `expected_row_ids`
177
- # is `nil`, not `[]`, when `policy.where` did not hold — no
178
- # dispatch is the claim then, not "dispatched to zero rows," and a
179
- # policy that dispatched anyway despite a failing guard is as real
180
- # a finding as a row it skipped.
181
- # @param history [Hash] a replayed history as returned by `Replay.call`
182
- # @return [true, String] true if every fan-out finding's actual dispatches
183
- # match its independently computed expected row set; otherwise a message
184
- # naming the policy, event, and disagreement
185
- def fanout_dispatches_once_per_matching_row(history)
186
- offenders = history.fetch(:fan_outs).filter_map do |finding|
187
- expected = finding[:expected_row_ids]
188
- actual = finding[:actual_row_ids].sort
189
-
190
- if expected.nil?
191
- next if actual.empty?
192
-
193
- next "#{finding[:policy]} on #{finding[:on]}: where did not hold, but dispatched to #{actual.inspect}"
194
- end
195
-
196
- next if actual == expected
197
-
198
- "#{finding[:policy]} on #{finding[:on]}: for_each answered #{expected.inspect}, " \
199
- "but the reaction log shows dispatches to #{actual.inspect}"
200
- end
201
-
202
- offenders.empty? || offenders.join("; ")
203
- end
204
-
205
- # A `count`/`median` report's reduced scalar matches the same
206
- # reduction done independently, over the same eligible rows —
207
- # `ReadModelInterpreter#project`'s own FK-join (root first, then
208
- # each many-side head matched against it) and `#median` (odd →
209
- # the true middle, even → the average of the two middles as a
210
- # Float, empty → `nil`; `count` is the filtered length, empty →
211
- # `0`), reproduced here in plain Ruby against `history[:instances]`
212
- # rather than a live registry — `FieldPath.dig` +
213
- # `Ports::Query::InMemory.comparable`/`.holds?` are the same two
214
- # calls the interpreter itself makes to read a field and judge a
215
- # `where`, called here rather than re-derived, so this oracle
216
- # cannot drift from what "read a field" or "a clause holds" mean
217
- # without the interpreter drifting the identical way.
218
- #
219
- # Only a report whose `:query` is answered by the same bluebook
220
- # `history[:bluebook]` carries (the bare `Domain.report_name`
221
- # form, `domain == bluebook.name`) is checked — the same "only
222
- # what we have the grammar for" scope `lifecycle_values_are_declared`
223
- # already takes for a multi-domain replay.
224
- # See the comment above: a closed chain of eligibility guards
225
- # ("only a report answered by this bluebook, only a count/median
226
- # report, only one with a reduced many-side head") each gating the
227
- # next, ending in one independent recomputation compared against
228
- # the live answer. The guards are what make this oracle scoped
229
- # correctly, not incidental complexity — narrower than "every
230
- # branch reads as its own precondition."
231
- # rubocop:disable-next Metrics/CyclomaticComplexity
232
- # rubocop:disable-next Metrics/PerceivedComplexity
233
- # @param history [Hash] a replayed history as returned by `Replay.call`
234
- # @return [true, String] true if every eligible count/median report answer
235
- # matches an independent recomputation; otherwise a message naming the
236
- # query and the disagreement
237
- def aggregation_matches_recompute(history)
238
- bluebook = history.fetch(:bluebook)
239
-
240
- offenders = history.fetch(:queries).filter_map do |asked|
241
- next if asked[:error]
242
-
243
- domain, name = asked[:query].to_s.split(".", 2)
244
- next unless name && domain == bluebook.name
245
-
246
- model = bluebook.read_model(name)
247
- next unless model && (model.count? || model.median_field)
248
-
249
- reduced_head = model.aggregate_heads.find { |head| head[:many] }
250
- next unless reduced_head
251
-
252
- rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, reduced_head, asked[:args] || {})
253
- expected = model.count? ? rows.length : recompute_median(rows, model.median_field)
254
- actual = asked[:rows]&.first&.dig(reduced_head[:as])
255
- next if actual == expected
256
-
257
- "#{asked[:query]} #{asked[:args].inspect} answered #{actual.inspect} for #{reduced_head[:as]}, " \
258
- "but recomputing independently from #{rows.length} eligible row(s) gives #{expected.inspect}"
259
- end
260
-
261
- offenders.empty? || offenders.join("; ")
262
- end
263
-
264
- # `aggregation_matches_recompute`'s own shape, extended from
265
- # reducing a many-side head to a scalar (count/median) to nesting
266
- # it — `ReadModelInterpreter#group_by_target`/`#nest`, reproduced
267
- # here in plain Ruby against `history[:instances]` the same way
268
- # `eligible_rows` already reproduces the FK-join and `where`
269
- # narrowing count/median share. `Value.materialize_unwrapped` is
270
- # the same call `#project` makes before nesting (a single-field
271
- # value object recurses to its bare scalar — a real grouping key
272
- # has to be one) — called here rather than re-derived, so this
273
- # oracle cannot drift from what "the group key" means without the
274
- # interpreter drifting the identical way.
275
- #
276
- # Real target: AccountsByKind (`group_by :kind, :number`,
277
- # rootless — always generator-eligible with `{}` args).
278
- # Same closed eligibility-guard chain as aggregation_matches_
279
- # recompute just above (see its own comment) — group_by in place
280
- # of count/median, nest_rows in place of recompute_median.
281
- # rubocop:disable-next Metrics/CyclomaticComplexity
282
- # rubocop:disable-next Metrics/PerceivedComplexity
283
- #
284
- # @param history [Hash] a replayed history as returned by `Replay.call`
285
- # @return [true, String] true if every eligible group_by report answer matches
286
- # an independent recompute; otherwise a semicolon-joined message naming each
287
- # offending query
288
- def group_by_matches_recompute(history)
289
- bluebook = history.fetch(:bluebook)
290
-
291
- offenders = history.fetch(:queries).filter_map do |asked|
292
- next if asked[:error]
293
-
294
- domain, name = asked[:query].to_s.split(".", 2)
295
- next unless name && domain == bluebook.name
296
-
297
- model = bluebook.read_model(name)
298
- next unless model&.group_by&.any?
299
-
300
- grouped_head = model.aggregate_heads.find { |head| head[:many] }
301
- next unless grouped_head
302
-
303
- rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, grouped_head, asked[:args] || {})
304
- materialized = rows.map { |state| Runtime::Value.materialize_unwrapped(state) }
305
- expected = nest_rows(materialized, model.group_by_fields)
306
- actual = asked[:rows]&.first&.dig(grouped_head[:as])
307
- next if actual == expected
308
-
309
- "#{asked[:query]} #{asked[:args].inspect} answered a #{grouped_head[:as]} grouping that disagrees " \
310
- "with independently nesting group_by #{model.group_by_fields.inspect} over #{rows.length} " \
311
- "eligible row(s)"
312
- end
313
-
314
- offenders.empty? || offenders.join("; ")
315
- end
316
-
317
- # `ReadModelInterpreter#nest`, byte for byte: one level of nesting
318
- # per `group_by` field in declared order, leaf is the row with
319
- # every grouped field stripped (already spent, as the keys that
320
- # reached it).
321
- # @param rows [Array<Hash>] materialized, symbol-keyed rows to nest
322
- # @param fields [Array<Symbol>] the `group_by` fields, in declared order
323
- # @return [Hash] one level of nesting per field, in order; the leaf under
324
- # each key path is the first row in that group with every grouped field
325
- # stripped
326
- def nest_rows(rows, fields)
327
- field, *rest = fields
328
- rows.group_by { |row| row[field] }.transform_values do |group|
329
- stripped = group.map { |row| row.reject { |key, _| key == field } }
330
- rest.empty? ? stripped.first : nest_rows(stripped, rest)
331
- end
332
- end
333
-
334
- # The eligible rows a `count`/`median` head reduces — every
335
- # instance of the reduced head's own aggregate, FK-matched against
336
- # the report's root reference (if it has one; a rootless report has
337
- # none to match) exactly the way `ReadModelInterpreter#reference_fields`
338
- # finds the matching attribute, then narrowed by the report's own
339
- # `where` clauses via the same `InMemory.holds?` the interpreter's
340
- # `execute` calls.
341
- # @param bluebook [Bluebook::Chapter] the bluebook the report belongs to
342
- # @param instances [Hash] the snapshot to read rows from (`history[:instances]`
343
- # shape, or the query's own `instances_at`)
344
- # @param domain [String] the domain name the reduced aggregate belongs to
345
- # @param model [Bluebook::ReadModel] the report being recomputed
346
- # @param reduced_head [Hash] the many-side aggregate head being reduced, from
347
- # `model.aggregate_heads`
348
- # @param args [Hash] the query's own arguments, for FK matching and `where`
349
- # evaluation
350
- # @return [Array<Hash>] every eligible row's own state, `id:` merged in
351
- def eligible_rows(bluebook, instances, domain, model, reduced_head, args)
352
- aggregate = bluebook.aggregate(reduced_head[:aggregate])
353
- prefix = "#{domain}::#{reduced_head[:aggregate]}#"
354
- # `id:` merged in, the same `record.to_h` (`@state.merge(id:
355
- # @id)`) every live head row carries — count/median never read
356
- # it, but group_by_matches_recompute's own independent nesting
357
- # does, the same way ReadModelInterpreter#row(record) = record.
358
- # to_h does for the live path it's checking against.
359
- rows = instances.filter_map { |key, state| state.merge(id: key.split("#").last) if key.start_with?(prefix) }
360
-
361
- if model.reference_target
362
- reference_id = args[model.reference_name].to_s
363
- fk_fields = aggregate.attributes.select do |attribute|
364
- attribute.reference? && attribute.type.target_name == model.reference_target.to_s
365
- end.map(&:name)
366
-
367
- rows = rows.select { |state| fk_fields.any? { |field| state[field].to_s == reference_id } }
368
- end
369
-
370
- rows.select do |state|
371
- model.wheres.all? do |clause|
372
- held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, clause.field))
373
- Ports::Query::InMemory.holds?(clause, held, args)
374
- end
375
- end
376
- end
377
-
378
- # `ReadModelInterpreter#median`'s own definition, reproduced byte
379
- # for byte: odd count → the true middle value, sorted; even count
380
- # → the average of the two middle values, as a Float; empty → nil,
381
- # never zero, so a caller cannot mistake "nothing to average" for
382
- # "averaged to zero."
383
- # @param rows [Array<Hash>] eligible rows, as returned by `#eligible_rows`
384
- # @param field [Symbol] the field to average
385
- # @return [Object, Float, nil] the true middle value for an odd count, the
386
- # Float average of the two middle values for an even count, or `nil` for
387
- # an empty `rows`
388
- def recompute_median(rows, field)
389
- values = rows.map { |state| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, field)) }
390
- .compact.sort
391
- return nil if values.empty?
392
-
393
- middle = values.length / 2
394
- values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
395
- end
396
- end
397
- end
398
- end
399
- end
@@ -1,137 +0,0 @@
1
- require_relative "../nondeterministic"
2
-
3
- module Hecks
4
- module Fuzzing
5
- module Properties
6
- # Lifecycle/saga-shape properties, and replay determinism itself: a
7
- # replayed history's lifecycle fields are among the aggregate's own
8
- # declared states, a saga's advances follow its declared handlers, and
9
- # replaying the same steps twice produces the same history.
10
-
11
- # Holds lifecycle_values_are_declared, saga_advances_follow_declared_handlers,
12
- # and replay_is_deterministic — the foundational property, since every
13
- # other property here trusts that a single replay's own history is
14
- # trustworthy in the first place.
15
- module LifecycleAndReplay
16
- # Every lifecycle field a replay leaves an instance holding is one
17
- # of the aggregate's own declared states — the full set, not just
18
- # `Lifecycle#states`' default+targets (see ModelCheck.full_states'
19
- # own comment on that hole). The tie to M2 is direct: the model
20
- # checker proves which states a domain's own declarations can ever
21
- # produce ; this proves a real run never produced anything else —
22
- # a coercion bug, a stale string surviving a rename, a default
23
- # that drifted from the declared set, would all show up here as a
24
- # value nothing upstream would have predicted.
25
- #
26
- # @param history [Hash] a replayed history, as returned by `Fuzzing::Replay.call`
27
- # @return [true, String] true if every instance's lifecycle field holds a declared
28
- # state; otherwise a semicolon-joined message naming each offending instance
29
- def lifecycle_values_are_declared(history)
30
- bluebook = history.fetch(:bluebook)
31
- declared = {}
32
- bluebook.aggregates.each do |aggregate|
33
- declared[aggregate.hecks_name] = Bluebook::ModelCheck.full_states(aggregate.lifecycle) if aggregate.lifecycle
34
- end
35
- return true if declared.empty?
36
-
37
- offenders = history.fetch(:instances).filter_map do |key, state|
38
- aggregate_name = key.split("::").last.split("#").first
39
- states = declared[aggregate_name]
40
- next unless states
41
-
42
- lifecycle = bluebook.aggregate(aggregate_name).lifecycle
43
- value = state[lifecycle.field]
44
- next if value.nil? || states.include?(value.to_s)
45
-
46
- "#{key} holds #{lifecycle.field}=#{value.inspect}, which #{aggregate_name} never declares as a state"
47
- end
48
-
49
- offenders.empty? || offenders.join("; ")
50
- end
51
-
52
- # Every saga advance a replay actually logged moved along an edge
53
- # the process manager declared — `(from, to)` pairs that appear in
54
- # `saga_log` with `advanced: true` must be a `(handler.from_state,
55
- # handler.to_state)` pair some handler on that PM declares
56
- # (compensation edges included ; a refused-triggered advance is a
57
- # handler like any other). A saga that advanced along a pair no
58
- # handler names would mean the runtime moved state the language
59
- # never authorized — the same trust ModelCheck's static reachability
60
- # rests on, checked here against what a run actually did.
61
- #
62
- # @param history [Hash] a replayed history as returned by `Replay.call`
63
- # @return [true, String] true if every logged advance matches a declared
64
- # handler edge; otherwise a message naming the process manager and pair
65
- # that does not
66
- def saga_advances_follow_declared_handlers(history)
67
- bluebook = history.fetch(:bluebook)
68
- edges = Hash.new { |h, k| h[k] = [] }
69
- bluebook.process_managers.each do |pm|
70
- pm.handlers.each { |handler| edges[pm.name] << [handler.from_state, handler.to_state] }
71
- end
72
- return true if edges.empty?
73
-
74
- offenders = history.fetch(:sagas).filter_map do |entry|
75
- next unless entry[:advanced]
76
-
77
- pair = [entry[:from], entry[:to]]
78
- next if edges[entry[:process_manager]].include?(pair)
79
-
80
- "#{entry[:process_manager]} advanced #{pair.inspect}, which no declared handler names"
81
- end
82
-
83
- offenders.empty? || offenders.join("; ")
84
- end
85
-
86
- # The foundational one. `Hecks::Runtime` mints nothing — every
87
- # identity is declared and derived, never invented (see
88
- # command_interpreter.rb's own "nothing is minted" — a random hex,
89
- # a counter, anything not reproducible from the payload, was
90
- # refused out of the runtime specifically because it broke this).
91
- # So the same steps, replayed against a fresh boot, must produce
92
- # byte-identical events, refusals, and instances — any drift here
93
- # is nondeterminism the runtime promised not to have: a wall-clock
94
- # read that leaked into compared state, a Hash iteration order a
95
- # comparison depended on, anything. Two independent replays, not a
96
- # cached one compared to itself, so a bug that corrupts the first
97
- # run's own bookkeeping cannot pass by agreeing with itself.
98
- #
99
- # @param domain_path [String] path to the domain directory to boot, such as
100
- # `"examples/pizzas"`
101
- # @param steps [Array<Hash>] the step list to replay twice
102
- # @param adapter [Symbol] persistence adapter to boot with (`:memory`,
103
- # `:postgres`, or `:postgres_era`)
104
- # @return [true, String] true if both replays produce identical histories
105
- # (after stripping declared nondeterministic fields); otherwise a message
106
- # naming the step count that diverged
107
- def replay_is_deterministic(domain_path, steps, adapter: :memory)
108
- first = Replay.call(domain_path, steps, adapter: adapter)
109
- second = Replay.call(domain_path, steps, adapter: adapter)
110
-
111
- # What leaves this comparison, and why, is declared once in
112
- # `Nondeterministic::FIELDS`: the `outbox_row` group (per-enqueue
113
- # uuids), the `event` group on each row's full `Event#to_h`
114
- # (`occurred_at`, which `history[:events]` never carried), and the
115
- # `history` group (live IR objects). Stripped per `outbox_traces`
116
- # row rather than dropping `outbox_traces` wholesale: everything
117
- # else on a row (status, consumer, kind, the event's own name/
118
- # aggregate/id/payload) is reproducible from the steps alone and
119
- # stays checked.
120
- strip_outbox_nondeterminism = lambda do |history|
121
- traces = Array(history[:outbox_traces]).map do |trace|
122
- trace.merge(rows: trace[:rows].map do |row|
123
- Nondeterministic.strip(row, :outbox_row).merge(event: Nondeterministic.strip(row[:event], :event))
124
- end)
125
- end
126
- history.merge(outbox_traces: traces)
127
- end
128
-
129
- comparable = ->(history) { Nondeterministic.strip(strip_outbox_nondeterminism.call(history), :history) }
130
- return true if comparable.call(first) == comparable.call(second)
131
-
132
- "two replays of the same #{steps.length} steps produced different histories"
133
- end
134
- end
135
- end
136
- end
137
- end