hecks 1.2.0 → 1.3.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. metadata +23 -2
@@ -0,0 +1,647 @@
1
+ require "json"
2
+ require "tmpdir"
3
+ require "open3"
4
+ require_relative "../adapters/driven/heki"
5
+ require_relative "../runtime/saga_interpreter"
6
+
7
+ module Hecks
8
+ module Fuzzing
9
+ # A SECOND COMPARISON AXIS — every OTHER check this practice runs is
10
+ # DIFFERENTIAL: Ruby vs the compiled Rust conformance binary, on the
11
+ # same generated sequence (`bin/qa_sweep`'s own `diff_ruby_vs_rust`,
12
+ # `Properties.check`). Differential comparison structurally cannot
13
+ # catch a bug where both engines are wrong the SAME way, or where a
14
+ # single engine is self-inconsistent with nothing to differentially
15
+ # compare it against. This module asks a DIFFERENT question of ONE
16
+ # engine at a time: does it agree with ITSELF?
17
+ #
18
+ # THREE CHECKS, run on the SAME already-generated sequence and its
19
+ # resulting live state — no second fuzzing pass, no re-dispatch
20
+ # through the command layer:
21
+ #
22
+ # 1. `check_rehydration` — does reloading an aggregate from its
23
+ # own durable journal reproduce exactly the state a live dispatch
24
+ # already produced?
25
+ # 2. `check_idempotency` — does replaying that SAME journal a
26
+ # second time change anything? (a variant of #1, but a different
27
+ # failure mode: no leaked state between applications, not just
28
+ # "cold load works once.")
29
+ # 3. `check_value_object_round_trip` — does every value object the
30
+ # sequence actually built survive `to_json` then rebuilt back?
31
+ #
32
+ # THE REHYDRATION PATH, FOUND, NOT GUESSED. hecks is not event-sourced
33
+ # at the aggregate level — there is no `AccountOpened`-shaped log a
34
+ # `CommandInterpreter` folds to rebuild state. What there IS, real and
35
+ # already shipping, is `Ports::Persistence::AppendOnly` (lib/hecks/
36
+ # ports/persistence/append_only.rb): every adapter accepts the same
37
+ # `Entry` stream (`operation`, `id`, the FULL state after that
38
+ # command — not a delta) and answers `#entries`; `#recover!` — "an
39
+ # append is durable before a projection is attempted; replaying the
40
+ # log restores a snapshot/table after a crash in that small window" —
41
+ # is called on EVERY repository this runtime ever builds
42
+ # (`RepositoryFactory.build`'s own `recover: true` default). That IS
43
+ # the production cold-rehydration path. Reusing `#recover!` directly
44
+ # against the LIVE adapter would prove nothing, though: `Fuzzing::
45
+ # Replay` runs against `Adapters::Memory` (`IsolatedBoot`'s own
46
+ # default), and Memory's own `Entry#state` is a SHALLOW `instance.
47
+ # state.dup` — the exact same `Runtime::Value` objects a command
48
+ # produced ride along unchanged, so folding them straight back through
49
+ # Memory's own `#project` is a tautology that can never fail (`Value#
50
+ # for_attribute`'s own `value.is_a?(self) && value.type_name == ...`
51
+ # branch passes an already-typed value straight through, no
52
+ # rebuilding at all).
53
+ #
54
+ # So `cold_read`, below, feeds the SAME entries through `Adapters::
55
+ # Heki` instead — a REAL, already-shipped, disk-backed adapter
56
+ # (examples/banking's own `persisted_by("Heki")`), in a throwaway
57
+ # directory. Writing forces every value through `JSON.generate`
58
+ # (Heki's own journal line, its own compressed snapshot); reading
59
+ # back through a FRESH `Heki` instance (unmemoized `@store`) forces
60
+ # `read_snapshot`/`replay_journal` — real `JSON.parse`, real
61
+ # `Zlib::Inflate`, real bytes off a real filesystem — which is what
62
+ # makes `Instance.hydrate_with_defaults` → `Value.hydrate` →
63
+ # `Value.for_attribute` actually REBUILD every value object from raw
64
+ # data via `Value.build`, the same coercion/validation path a real
65
+ # restart takes, rather than pass the live object through unchanged.
66
+ # This is the exact mechanism `AppendOnly#recover!` names in its own
67
+ # comment ("restores a snapshot/table after a crash"), just exercised
68
+ # against the one adapter whose own `#entries` actually forces the
69
+ # JSON boundary Memory's does not.
70
+ module SelfConsistency
71
+ module_function
72
+
73
+ # THE WHOLE PASS — called once, with the runtime STILL LIVE (inside
74
+ # `Replay.call`'s own `IsolatedBoot.call` block, before the tmp
75
+ # directory and its adapters go out of scope) and the `history`
76
+ # `Replay.call` is about to return. Nothing here boots a second
77
+ # runtime or dispatches a single command; every check below reads
78
+ # data this ONE replay already produced. Just the three checks
79
+ # below, run and collected — kept as three independently callable
80
+ # methods (not fused into one shared fold) so a spec proving one
81
+ # check can fire never has to reason about the other two.
82
+ def check(runtime, history)
83
+ { rehydration: check_rehydration(runtime), idempotency: check_idempotency(runtime),
84
+ value_object_round_trip: check_value_object_round_trip(history),
85
+ saga_rehydration: check_saga_rehydration(runtime, history),
86
+ saga_redelivery_idempotency: check_saga_idempotency(runtime, history) }
87
+ end
88
+
89
+ # CHECK 1 — REHYDRATE-FROM-JOURNAL == LIVE STATE.
90
+ def check_rehydration(runtime)
91
+ each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
92
+ live = snapshot(repository)
93
+ Dir.mktmpdir("hecks-self-consistency") do |tmp|
94
+ writer = Adapters::Heki.new(aggregate: aggregate, root: tmp)
95
+ rehydrated = fold!(writer, tmp, aggregate, entries)
96
+ next if rehydrated == live
97
+
98
+ { field: "rehydration", domain: domain_name, aggregate: aggregate.hecks_name,
99
+ live: live, rehydrated: rehydrated }
100
+ end
101
+ end
102
+ end
103
+
104
+ # CHECK 2 — REPLAY IDEMPOTENCY: folding the SAME entries into the
105
+ # SAME durable store a second time must change nothing. A variant
106
+ # of check 1, worth stating separately — this catches a
107
+ # replay-specific bug (leaked state between applications, a
108
+ # double-applied effect) that a single, one-shot cold read could
109
+ # never see, even one that already agrees with live state.
110
+ def check_idempotency(runtime)
111
+ each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
112
+ Dir.mktmpdir("hecks-self-consistency") do |tmp|
113
+ writer = Adapters::Heki.new(aggregate: aggregate, root: tmp)
114
+ once = fold!(writer, tmp, aggregate, entries)
115
+ twice = fold!(writer, tmp, aggregate, entries)
116
+ next if once == twice
117
+
118
+ { field: "idempotency", domain: domain_name, aggregate: aggregate.hecks_name,
119
+ once: once, twice: twice }
120
+ end
121
+ end
122
+ end
123
+
124
+ # CHECK 3 — every `Runtime::Value` the sequence actually built
125
+ # (walked out of the replay's own `instances`/`events`/`queries`,
126
+ # never a hand-picked example), round-tripped through the REAL
127
+ # serialize/deserialize pair: `Value#to_json` (JSON.generate(to_h),
128
+ # value.rb) out, `Value.build` (value/coercion.rb — the same
129
+ # constructor a command argument's own raw JSON goes through) back
130
+ # in. There is no class-level `VO.from_json` in this codebase (that
131
+ # spelling is Rust's — rust/src/exemplar/json.rs's generated
132
+ # `from_json` per closed set/value object); `Value.build` is the
133
+ # actual Ruby door a raw, untyped Hash becomes a validated,
134
+ # admitted, invariant-checked value object through.
135
+ # `aggregate:` THREADED ALONGSIDE EVERY VALUE FOUND, NOT DROPPED —
136
+ # `Value.build(value_object, fields, aggregate)`'s third argument is
137
+ # what lets `normalize_composite_fields` resolve a NESTED composite
138
+ # field's own type by name (`value_object_for(aggregate, type)`).
139
+ # Building with `aggregate: nil` (this method's first version, live-
140
+ # tested against `examples/pizzas` while this was being written)
141
+ # silently skips that step entirely — `Pizza`'s own `price_cents`/
142
+ # `size` fields round-tripped back as bare, STRING-keyed Hashes
143
+ # instead of rebuilt `Money`/`PizzaSize` value objects, a false
144
+ # POSITIVE this check would have reported as a real bug on every
145
+ # single sweep. Resolved from `history[:instances]`' own key
146
+ # (`"Domain::Aggregate#id"`, `Replay#snapshot_instances`) and
147
+ # `history[:events]`' own `event[:aggregate]` (`"Domain::Aggregate"`,
148
+ # domain-qualified) against `history[:bluebooks]` — the exact same
149
+ # loaded chapter map every other replay-time check already reads
150
+ # off `history` rather than a second lookup. `history[:queries]`'
151
+ # own rows have no single owning aggregate reliably named on the
152
+ # entry itself (a cross-aggregate read model, a `for_each` target),
153
+ # so they are left OUT of this walk rather than risk the same false
154
+ # positive `nil` already produced once — `instances` and `events`
155
+ # alone already reach every value object a generated sequence
156
+ # actually persisted or announced.
157
+ def check_value_object_round_trip(history)
158
+ bluebooks = history[:bluebooks] || {}
159
+ seen = {}.compare_by_identity
160
+ found = []
161
+
162
+ history[:instances].each do |key, state|
163
+ domain_name, aggregate_name = key.to_s.split("#", 2).first.to_s.split("::", 2)
164
+ aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
165
+ walk_value_objects(state, found, seen, aggregate)
166
+ end
167
+
168
+ history[:events].each do |event|
169
+ domain_name, aggregate_name = event[:aggregate].to_s.split("::", 2)
170
+ aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
171
+ walk_value_objects(event[:payload], found, seen, aggregate)
172
+ end
173
+
174
+ found.filter_map do |value, aggregate|
175
+ begin
176
+ rebuilt = Runtime::Value.build(value.value_object, JSON.parse(value.to_json), aggregate)
177
+ rescue StandardError => e
178
+ next { field: "value_object_round_trip", type: value.type_name, original: value.to_h,
179
+ error: "#{e.class}: #{e.message}" }
180
+ end
181
+
182
+ next if rebuilt == value
183
+
184
+ { field: "value_object_round_trip", type: value.type_name, original: value.to_h,
185
+ rehydrated: rebuilt.to_h }
186
+ end
187
+ end
188
+
189
+ # CHECK 4 — SAGA COLD-REHYDRATION (ANGLE-10). Checks 1/2 above cold-
190
+ # read an AGGREGATE's own journal through Heki; nothing in this file
191
+ # ever exercised the OTHER durable store `SagaInterpreter#checkpoint`
192
+ # writes through — `Ports::Persistence::NullSagaStore`'s own header
193
+ # calls Heki's `SagaStore` (`adapters/driven/heki/saga_store.rb`) the
194
+ # OPTIONAL saga-persistence capability, and it is real and already
195
+ # shipping, just never fuzzed: `Registry#rehydrate_sagas!` — the
196
+ # production "process just restarted" path — folds exactly what
197
+ # `each_saga` yields back into `@saga_instances`, and until now
198
+ # nothing ever proved that round trip faithful for a sequence this
199
+ # practice actually generated. `BUG#6`/`#9`/`#10` all came out of
200
+ # this exact interpreter, which is why this checks it specifically
201
+ # rather than folding it into checks 1/2's own aggregate walk.
202
+ #
203
+ # `history[:saga_instances]` (`replay.rb`'s own `saga_instances`
204
+ # local, built once at the very end of a replay) is the SAME
205
+ # materialized `{pm_name => {correlation => {state:, memory:}}}`
206
+ # shape `SagaInterpreter#checkpoint` itself hands a real adapter —
207
+ # read from `history`, not re-derived from the (by-now-live, already
208
+ # mutated by whatever `check_saga_idempotency` ran first, see that
209
+ # method's own header) `runtime.registry.saga_instances`. Written
210
+ # through a REAL `Adapters::Heki` (a throwaway tmpdir, one per
211
+ # process manager so two process managers with correlations that
212
+ # happen to collide as strings never share a store), read back
213
+ # through a FRESH instance (unmemoized `@store`/`@saga_store`, same
214
+ # reason `fold!` above uses one) — forcing the identical
215
+ # `JSON.generate`/`JSON.parse` boundary a real crash-then-restart
216
+ # takes, not a live-object pass-through.
217
+ #
218
+ # ONE FINDING PER (domain, process manager) — every correlation this
219
+ # process manager's own `history[:saga_instances]` entry holds,
220
+ # compared as a whole Hash — the same aggregate-granularity (not
221
+ # per-record) `check_rehydration` already reports at.
222
+ #
223
+ # `completed_compensations` is DELIBERATELY OUT OF SCOPE — `history[
224
+ # :saga_instances]` never captures it (`replay.rb`'s own comment:
225
+ # only `state`/`memory` are threaded through, since a saga's
226
+ # in-flight compensation ledger is a fact about a leg still running,
227
+ # not the settled snapshot this history exists to describe), so
228
+ # there is no ground truth to compare it against here. Written as an
229
+ # empty array on the way in and never read back on the way out.
230
+ def check_saga_rehydration(runtime, history)
231
+ saga_instances = history[:saga_instances] || {}
232
+ each_domain_process_manager(runtime).filter_map do |domain_name, process_manager|
233
+ persisted = saga_instances[process_manager.name]
234
+ next if persisted.nil? || persisted.empty?
235
+
236
+ anchor = runtime.registry.bluebook(domain_name).aggregates.first
237
+ next unless anchor
238
+
239
+ Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
240
+ writer = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
241
+ persisted.each do |correlation, saga|
242
+ writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
243
+ state: saga[:state], memory: saga[:memory], completed_compensations: [])
244
+ end
245
+
246
+ live = normalize_saga_rows(persisted)
247
+ rehydrated = cold_read_saga_rows(anchor, tmp, domain_name)
248
+ next if rehydrated == live
249
+
250
+ { field: "saga_rehydration", domain: domain_name, process_manager: process_manager.name,
251
+ live: live, rehydrated: rehydrated }
252
+ end
253
+ end
254
+ end
255
+
256
+ # CHECK 5 — REDELIVERY IDEMPOTENCY OF THE CHECKPOINT-THEN-LOAD PATH.
257
+ # `check_saga_rehydration` above proves cold-reading a checkpoint
258
+ # reproduces the same DATA; this proves the OTHER half of a real
259
+ # crash/restart — a message an at-least-once delivery mechanism (an
260
+ # outbox redrive, a queue redelivery) hands the rehydrated saga a
261
+ # SECOND time — does not silently re-advance it. There is no flag
262
+ # for this in production (`SagaInterpreter#unwind`'s own comment:
263
+ # "the check is the guard") — the (event, current state) lookup
264
+ # `handler_for` performs is the ENTIRE mechanism, and it has never
265
+ # been exercised against a state this practice loaded from cold
266
+ # storage rather than one still sitting in a live process's memory.
267
+ #
268
+ # ONE (PROCESS MANAGER, CORRELATION) TESTED, using a fresh
269
+ # `Runtime::SagaInterpreter` sharing `runtime`'s own `registry` and
270
+ # `door: runtime` — the identical two objects the DISPATCHER'S own
271
+ # `@sagas` was built from (`dispatcher.rb`'s own `SagaInterpreter.
272
+ # new(registry, door: self)`) — not a hand-rolled re-implementation
273
+ # of `advance_saga`'s own state-guard. `only: process_manager` scopes
274
+ # the redelivery to exactly the one procedure under test, the same
275
+ # keyword the outbox relay already uses to run one consumer alone
276
+ # (`Runtime::Outbox::Relay#run_consumer`).
277
+ #
278
+ # WHICH EVENT TO REDELIVER — `runtime.registry.saga_log`'s own last
279
+ # `advanced: true` row for this (process manager, correlation) names
280
+ # the event BY NAME ONLY; the REAL `Runtime::Event` object (payload,
281
+ # aggregate, id, `correlation` — everything `saga_correlation`/
282
+ # `dispatch_args` actually read) lives in `runtime.events`, still
283
+ # live for exactly this reason (this file's own header: "runtime IS
284
+ # STILL LIVE HERE"). Matched back by NAME plus `saga_correlation`
285
+ # itself (`Runtime::SagaInterpreter::Correlation`, `private`) —
286
+ # reused via `send` rather than reproduced, because reproducing its
287
+ # three-tier fallback (a dotted payload field, a stamped passthrough,
288
+ # a self-identifying `event.id`) here would be exactly the
289
+ # hand-rolled approximation this file was told not to build. A
290
+ # `:refused`-driven (compensating) transition is skipped outright —
291
+ # its own `saga_log` row's `on:` is the synthetic `REFUSED` trigger
292
+ # name, never a real domain event, so there is nothing to redeliver.
293
+ #
294
+ # SIMULATING "JUST RESTARTED" — the live registry's own in-memory
295
+ # `saga_instances[pm][correlation]` slot is overwritten, IN PLACE,
296
+ # with whatever a cold Heki read of the SAME checkpoint answers
297
+ # (exactly what `Registry#rehydrate_sagas!` does for real on every
298
+ # boot), the redelivery is driven through the real interpreter, and
299
+ # the slot is put back — `ensure`d — once this correlation's own
300
+ # check is done. Safe ONLY because `check`/`Replay.call` run this,
301
+ # synchronously, single-threaded, as the very last thing before
302
+ # `runtime` and its whole tmp directory go out of scope for good;
303
+ # nothing downstream of this method ever reads the LIVE registry
304
+ # again (`check_saga_rehydration`, `check_rehydration`, `check_
305
+ # idempotency`, `check_value_object_round_trip` all read `history`'s
306
+ # own frozen snapshot instead, never `runtime.registry` — so calling
307
+ # order relative to this method's own mutation doesn't matter).
308
+ #
309
+ # THE ASSERTION IS ABOUT `state`/`memory`, NOT "did a dispatch fire"
310
+ # — a leg whose own `from:`/`to:` are the SAME state (every existing
311
+ # saga's own starts_on self-transition, `waybill.bluebook`'s own leg
312
+ # 1/2) is EXPECTED to re-run on redelivery with no visible state
313
+ # change at all; that is a property of the declared handler graph,
314
+ # not a rehydration defect, and asserting against it here would
315
+ # manufacture a false positive on every saga this corpus has. A
316
+ # correlation whose current state has no declared handler at all for
317
+ # the redelivered event name (the ordinary, expected case once a
318
+ # saga has moved past the leg that produced its own current
319
+ # checkpoint) is exactly what this proves stays put.
320
+ def check_saga_idempotency(runtime, history)
321
+ saga_instances = history[:saga_instances] || {}
322
+ interpreter = Runtime::SagaInterpreter.new(runtime.registry, door: runtime)
323
+
324
+ each_domain_process_manager(runtime).flat_map do |domain_name, process_manager|
325
+ persisted = saga_instances[process_manager.name]
326
+ next [] if persisted.nil? || persisted.empty?
327
+
328
+ anchor = runtime.registry.bluebook(domain_name).aggregates.first
329
+ next [] unless anchor
330
+
331
+ persisted.filter_map do |correlation, saga|
332
+ redelivery = last_advancing_event(runtime, interpreter, process_manager, correlation)
333
+ next unless redelivery
334
+
335
+ check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
336
+ correlation, saga, redelivery)
337
+ end
338
+ end
339
+ end
340
+
341
+ # ── Rust-side self-consistency ───────────────────────────────────
342
+ #
343
+ # THE COMPILED BINARY'S OWN REHYDRATION DOOR, ALREADY SHIPPING —
344
+ # `kernel/cli.rs`'s `run` accepts an OPTIONAL top-level `"seed"` key
345
+ # ("the exact 'Domain::Aggregate#id' -> state shape THIS run's own
346
+ # 'instances' output already produces... lets a HOST seed prior
347
+ # state back in instead of replaying `steps` from scratch every
348
+ # invocation"). `Store::from_seed`/`Store::instances` are that
349
+ # mechanism's own two halves — `rust/host` (docs/implemented/
350
+ # decisions/0012) depends on them being true inverses for real,
351
+ # today. `rust_seed_round_trip` exercises exactly that: a SECOND,
352
+ # independent invocation of the SAME binary, `"steps": []` (nothing
353
+ # new dispatched — this is rehydration, not re-dispatch), seeded
354
+ # with whatever `"instances"` a PRIOR invocation already produced.
355
+ # `differ` (unused, kept in the signature — see below) is a
356
+ # `RustConformanceHelpers`-including instance (`bin/qa_sweep`'s own
357
+ # `Differ`), the same argument `check_rust_rehydration`/`check_
358
+ # rust_idempotency` already take from their own callers; kept here
359
+ # rather than dropped from all three signatures at once so a
360
+ # future differential-style reduction has a door already open,
361
+ # without this module ever `require`ing `spec/support/` itself.
362
+ # NEITHER `strip_emitted_flags!` NOR ANY OTHER DIFFERENTIAL-ONLY
363
+ # REDUCTION RUNS HERE — that reduction exists so a Rust-only
364
+ # bookkeeping field (`emitted_<event>`, docs/decisions/0049) never
365
+ # counts against Ruby, which has no equivalent field to agree with
366
+ # at all (`RustConformanceHelpers#strip_emitted_flags!`'s own
367
+ # comment). This check has no Ruby side to spare — it is asking the
368
+ # Rust binary whether it agrees with ITSELF, so `emitted_*` fields
369
+ # are exactly as real a fact to compare as any other. Stripping them
370
+ # here (an earlier version of this method did) silently deleted
371
+ # them from the RETURNED seed-round-trip result while leaving them
372
+ # present on the ORIGINAL `seed_instances` a caller passes in — an
373
+ # asymmetric comparison that reported EVERY record carrying one as
374
+ # a rehydration divergence, unconditionally, on every domain that
375
+ # has one at all. Found live against `examples/banking`
376
+ # (`Banking::Account`'s own `corrects` reaction) while this
377
+ # integration was being written, by comparing this method's own
378
+ # answer against the compiled binary's RAW stdout for the identical
379
+ # seed call: the raw round trip preserved `emitted_fee_applied`
380
+ # correctly; only THIS method's own stripping dropped it. `spec/
381
+ # self_consistency_rust_spec.rb`'s own "banking" example pins the
382
+ # regression against a real domain going forward.
383
+ def rust_seed_round_trip(binary, _differ, seed_instances)
384
+ stdout, status = Open3.capture2(binary, stdin_data: JSON.generate({ "steps" => [], "seed" => seed_instances }))
385
+ return { "__self_consistency_error__" => "rust binary exited #{status.exitstatus}: #{stdout}" } \
386
+ unless status.success?
387
+
388
+ parsed = JSON.parse(stdout)
389
+ return { "__self_consistency_error__" => parsed["error"] } if parsed["error"]
390
+
391
+ parsed["instances"]
392
+ end
393
+
394
+ # CHECK 1, RUST SIDE — seeding a fresh invocation with a PRIOR
395
+ # invocation's own live `"instances"` must reproduce that same
396
+ # state, unchanged. `live_instances` is `rust_output["instances"]`
397
+ # — the exact same value `bin/qa_sweep`'s own differential compare
398
+ # already diffed against Ruby, reused here rather than re-derived.
399
+ def check_rust_rehydration(binary, differ, live_instances)
400
+ rehydrated = rust_seed_round_trip(binary, differ, live_instances)
401
+ return [] if rehydrated == live_instances
402
+
403
+ [{ field: "rust_rehydration", live: live_instances, rehydrated: rehydrated }]
404
+ end
405
+
406
+ # CHECK 2, RUST SIDE — seeding with what a first seed round trip
407
+ # already produced, a SECOND time, must not drift any further. The
408
+ # same "replay it again, byte for byte" claim `check_idempotency`
409
+ # proves for Ruby, aimed at the one rehydration door this compiled
410
+ # binary actually has.
411
+ def check_rust_idempotency(binary, differ, live_instances)
412
+ once = rust_seed_round_trip(binary, differ, live_instances)
413
+ twice = rust_seed_round_trip(binary, differ, once)
414
+ return [] if once == twice
415
+
416
+ [{ field: "rust_idempotency", once: once, twice: twice }]
417
+ end
418
+
419
+ # ── shared plumbing ─────────────────────────────────────────────
420
+
421
+ # EVERY [domain, aggregate] PAIR THIS SEQUENCE ACTUALLY WROTE TO —
422
+ # an aggregate with an empty `#entries` never had anything dispatch
423
+ # against it this run, so there is nothing to rehydrate and no
424
+ # finding a "clean, nothing touched" report would mean anything
425
+ # for. Mirrors `Replay#snapshot_instances`' own
426
+ # `bluebooks.each { aggregates.each { repository(...) } }` walk.
427
+ def each_touched_repository(runtime)
428
+ found = []
429
+ runtime.registry.bluebooks.each do |domain_name, bluebook|
430
+ bluebook.aggregates.each do |aggregate|
431
+ repository = runtime.registry.repository(domain_name, aggregate)
432
+ entries = repository.entries
433
+ next if entries.empty?
434
+
435
+ found << [domain_name, aggregate, repository, entries]
436
+ end
437
+ end
438
+ found
439
+ end
440
+
441
+ def snapshot(repository)
442
+ repository.all.to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
443
+ end
444
+
445
+ # EVERY [domain, process manager] PAIR ANY LOADED BLUEBOOK DECLARES —
446
+ # regardless of whether this replay's own `history[:saga_instances]`
447
+ # ever touched it (mirrors `each_touched_repository`'s own walk one
448
+ # level up; the "did anything actually persist" filter lives in each
449
+ # check's own caller, same as that method's `entries.empty?` guard).
450
+ # An empty return here IS the "domain declares no process manager"
451
+ # skip `check_saga_rehydration`/`check_saga_idempotency` both need —
452
+ # `filter_map`/`flat_map` over an empty Array already answers `[]`,
453
+ # identical to "ran and found nothing," which is deliberate: neither
454
+ # check has a positive "passed" artifact to report either way (see
455
+ # this file's own header on why silence is never a claimed pass).
456
+ def each_domain_process_manager(runtime)
457
+ found = []
458
+ runtime.registry.bluebooks.each do |domain_name, bluebook|
459
+ bluebook.process_managers.each { |pm| found << [domain_name, pm] }
460
+ end
461
+ found
462
+ end
463
+
464
+ # THE LIVE-SIDE GROUND TRUTH, key-shape-normalized (see `deep_
465
+ # stringify_keys`'s own comment) so it compares fairly against a
466
+ # real Heki round trip's own shallow-symbolize convention.
467
+ def normalize_saga_rows(persisted)
468
+ persisted.each_with_object({}) do |(correlation, saga), rows|
469
+ rows[correlation.to_s] = { state: saga[:state], memory: deep_stringify_keys(saga[:memory]) }
470
+ end
471
+ end
472
+
473
+ # A FRESH `Adapters::Heki` AT THE SAME `tmp`/`domain` — unmemoized
474
+ # `@store`/`@saga_store`, so `#each_saga` is forced back through
475
+ # `read_snapshot`/`replay_journal`, real bytes off real disk, not
476
+ # whatever the writer that just wrote them still holds in its own
477
+ # process memory (the same reason `fold!`, above, opens a second
478
+ # `Adapters::Heki` instance rather than reading its own writer back).
479
+ def cold_read_saga_rows(anchor, tmp, domain_name)
480
+ reader = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
481
+ reader.each_saga.with_object({}) do |(_pm, correlation, state, memory, _completed), rows|
482
+ rows[correlation] = { state: state, memory: deep_stringify_keys(memory) }
483
+ end
484
+ end
485
+
486
+ # `SagaStore#each_saga` ONLY EVER SYMBOLIZES `memory`'S OWN TOP-LEVEL
487
+ # KEYS (`heki/saga_store.rb`'s own `each_saga`, one level deep) —
488
+ # `Registry::SagaPersistence#warn_stalled_saga` already documents
489
+ # this exact asymmetry for the one reserved key production code
490
+ # cares about (`SAGA_PENDING_DISPATCH_KEY`). A NESTED composite
491
+ # memory field (any saga whose starting event carries a value
492
+ # object, which is most of them — `waybill.bluebook`'s own
493
+ # `ConsignmentRequested` alone has three) comes back with STRING
494
+ # keys at every level BELOW the top, while `history[:saga_instances]`
495
+ # 's own `Runtime::Value.materialize` call produces SYMBOL keys
496
+ # throughout. That asymmetry is Heki's own documented, accepted
497
+ # storage convention — an "opaque, adapter-agnostic JSON blob"
498
+ # (`SagaInterpreter#checkpoint`'s own comment), never a typed
499
+ # rebuild the way an AGGREGATE's own composite fields get on cold
500
+ # read (this file's own header: there is no VO schema to rebuild
501
+ # against for a saga's memory blob at all) — not a rehydration
502
+ # defect this check exists to find. Recursively re-stringifying
503
+ # BOTH sides before comparing is what tells that KNOWN, accepted
504
+ # shape difference apart from an ACTUAL data-loss bug (a dropped
505
+ # key, a changed value, a missing field) — exactly the kind (b)'s
506
+ # own seeded fixture in `spec/fuzzing/self_consistency_saga_spec.rb`
507
+ # proves this still catches.
508
+ def deep_stringify_keys(value)
509
+ case value
510
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
511
+ when Array then value.map { |item| deep_stringify_keys(item) }
512
+ else value
513
+ end
514
+ end
515
+
516
+ # THE REAL, ALREADY-ANNOUNCED EVENT this correlation's CURRENT
517
+ # checkpoint came from — walked back out of `runtime.registry.
518
+ # saga_log`'s own `advanced: true` rows (newest first), skipping the
519
+ # synthetic `REFUSED` trigger (`Runtime::SagaInterpreter::REFUSED`
520
+ # — a compensating transition's own log entry names that, never a
521
+ # real domain event; there is nothing in `runtime.events` to
522
+ # redeliver for it). `nil` when no real advancing event exists at
523
+ # all (a correlation only ever `begin_saga`'d, never advanced) —
524
+ # `check_saga_idempotency`'s own caller skips a `nil` outright,
525
+ # exactly like `each_touched_repository`'s own "nothing to check"
526
+ # skip one level up.
527
+ #
528
+ # `interpreter.send(:saga_correlation, ...)` — `Correlation` is
529
+ # `private`, and reproducing its own three-tier fallback (a dotted
530
+ # payload field, a stamped passthrough, a self-identifying
531
+ # `event.id`) here rather than reusing it would be exactly the
532
+ # "hand-rolled approximation" this check exists to avoid; `send` on
533
+ # an interpreter sharing this SAME `runtime`'s own registry is the
534
+ # real thing, not a copy of it.
535
+ def last_advancing_event(runtime, interpreter, process_manager, correlation)
536
+ entry = runtime.registry.saga_log.reverse_each.find do |row|
537
+ row[:process_manager] == process_manager.name && row[:instance] == correlation &&
538
+ row[:advanced] && row[:on] != Runtime::SagaInterpreter::REFUSED
539
+ end
540
+ return nil unless entry
541
+
542
+ runtime.events.reverse_each.find do |event|
543
+ event.name == entry[:on] && interpreter.send(:saga_correlation, process_manager, event) == correlation
544
+ end
545
+ end
546
+
547
+ # ONE (process manager, correlation)'s OWN redelivery check — pulled
548
+ # out of `check_saga_idempotency` itself so that method's own
549
+ # `flat_map`/`filter_map` walk stays readable; every local this
550
+ # shares with its caller (`interpreter`, `anchor`) is passed in
551
+ # rather than re-derived.
552
+ def check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
553
+ correlation, saga, redelivery)
554
+ Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
555
+ writer = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
556
+ writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
557
+ state: saga[:state], memory: saga[:memory], completed_compensations: [])
558
+
559
+ rehydrated = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
560
+ .each_saga.find { |_pm, corr, *| corr == correlation.to_s }
561
+ next unless rehydrated
562
+
563
+ _pm, _corr, state, memory, compensations = rehydrated
564
+ before = { state: state, memory: deep_stringify_keys(memory) }
565
+
566
+ saga_instances = runtime.registry.saga_instances[process_manager.name]
567
+ original = saga_instances[correlation]
568
+ begin
569
+ saga_instances[correlation] = { state: state, memory: memory, completed_compensations: compensations || [] }
570
+ interpreter.advance(redelivery, domain_name, only: process_manager)
571
+
572
+ after = saga_instances[correlation]
573
+ after_shape = after && { state: after[:state], memory: deep_stringify_keys(after[:memory]) }
574
+ next if after_shape == before
575
+
576
+ { field: "saga_redelivery_idempotency", domain: domain_name, process_manager: process_manager.name,
577
+ correlation: correlation, on: redelivery.name, before: before, after: after_shape }
578
+ ensure
579
+ if original
580
+ saga_instances[correlation] = original
581
+ else
582
+ saga_instances.delete(correlation)
583
+ end
584
+ end
585
+ end
586
+ end
587
+
588
+ # ONE FOLD OF `entries` INTO `writer` (a real, already-open `Heki`
589
+ # adapter at `tmp`), THEN A COLD READ BACK through a BRAND NEW `Heki`
590
+ # instance at the SAME path — `@store` on a fresh instance starts
591
+ # unmemoized, so `#all` below is forced through `#read` →
592
+ # `#read_snapshot`/`#replay_journal`, real disk bytes, not whatever
593
+ # `writer` still holds cached in its own process memory. Called
594
+ # TWICE in a row against the SAME `writer` (see `check`/
595
+ # `check_idempotency` above) is exactly "replay the same journal a
596
+ # second time" — `writer` already holds everything the first fold
597
+ # wrote, so a second fold re-applies the identical operations on
598
+ # top, and the two cold reads either agree (idempotent) or don't.
599
+ def fold!(writer, tmp, aggregate, entries)
600
+ entries.each do |entry|
601
+ writer.append(entry)
602
+ writer.project(entry)
603
+ end
604
+
605
+ Adapters::Heki.new(aggregate: aggregate, root: tmp).all
606
+ .to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
607
+ end
608
+
609
+ # RECURSES THROUGH A `Value`'S OWN FIELDS VIA `#[]`, NOT `#to_h` —
610
+ # `#to_h` already materializes every field (`Value.materialize`),
611
+ # which would hide a nested `Value` from this walk before it ever
612
+ # got here. In practice a value object's OWN composite fields
613
+ # (`Coercion#normalize_composite_fields`) are validated but stored
614
+ # as plain, already-materialized Hashes, not re-wrapped `Value`
615
+ # instances — confirmed live, not assumed — so this recursion finds
616
+ # nothing further past the field it started from FOR TODAY'S
617
+ # coercion pipeline specifically. Kept anyway, not dead code: the
618
+ # generic `Hash`/`Array` branches below reach the exact same nested
619
+ # data through `node[attribute.name]` regardless, and a future
620
+ # change that DOES start wrapping composite fields as real `Value`
621
+ # instances would be walked correctly here with no change needed.
622
+ # `seen` is a `compare_by_identity` Hash: the same INSTANCE can legitimately
623
+ # appear more than once (an aggregate's live state and an event
624
+ # payload both reference the exact same frozen object), and
625
+ # checking it twice would just waste time, never change the
626
+ # answer — identity, not `Value#==`, is the right notion of
627
+ # "already found" here (two DIFFERENT value objects that happen to
628
+ # hold equal fields are still two separate round trips to prove).
629
+ def walk_value_objects(node, found, seen, aggregate)
630
+ case node
631
+ when Runtime::Value
632
+ return if seen[node]
633
+
634
+ seen[node] = true
635
+ found << [node, aggregate]
636
+ node.value_object.attributes.each do |attribute|
637
+ walk_value_objects(node[attribute.name], found, seen, aggregate)
638
+ end
639
+ when Hash
640
+ node.each_value { |value| walk_value_objects(value, found, seen, aggregate) }
641
+ when Array
642
+ node.each { |value| walk_value_objects(value, found, seen, aggregate) }
643
+ end
644
+ end
645
+ end
646
+ end
647
+ end