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
@@ -1,6 +1,39 @@
1
1
  module Hecks
2
2
  module Fuzzing
3
3
  module Properties
4
+ # A DRY RUN LEAVES NO TRACE — `Dispatcher#dry_run?`'s whole contract
5
+ # ("the command evaluated hypothetically, nothing saved or emitted,
6
+ # no reaction"), held to the store rather than trusted: `Replay`
7
+ # snapshots every instance and the event count on either side of
8
+ # each `{"dry_run": …}` step, in a SEPARATE `dry_run_traces` array
9
+ # (same order as `history[:dry_runs]`, never merged into it — that
10
+ # array stays the exact `{verb:, ok:, error?:}` shape the compiled
11
+ # Rust binary's own `dry_run` answers, so `spec/rust_conformance_
12
+ # spec.rb`'s direct comparison against it never sees a key Rust
13
+ # doesn't have), and this is the comparison. Refused or accepted
14
+ # makes no difference — a hypothetical that was refused had even
15
+ # less business writing anything. Entries without the snapshots (a
16
+ # hand-built history, an older corpus) are skipped, not failed: no
17
+ # claim, no finding.
18
+ module DryRuns
19
+ def dry_runs_leave_no_trace(history)
20
+ offenders = Array(history[:dry_run_traces]).filter_map do |entry|
21
+ before = entry[:before]
22
+ after = entry[:after]
23
+ next unless before && after
24
+
25
+ traces = []
26
+ traces << "events #{before[:events]} -> #{after[:events]}" unless before[:events] == after[:events]
27
+ traces << "instances changed" unless before[:instances] == after[:instances]
28
+ next if traces.empty?
29
+
30
+ "dry run of #{entry[:verb]} (ok: #{entry[:ok]}) left a trace: #{traces.join(', ')}"
31
+ end
32
+
33
+ offenders.empty? || offenders.join("; ")
34
+ end
35
+ end
36
+
4
37
  # Dispatch-binding and mutation-recomputation properties: a saga/
5
38
  # policy dispatch is bound to the value its own with_spec names, and a
6
39
  # command's append/remove/multiply/clamp mutations land on the same
@@ -140,8 +173,14 @@ module Hecks
140
173
  command = command_for_verb(bluebooks, entry[:verb])
141
174
  next [] unless command
142
175
 
176
+ aggregate = aggregate_for_verb(bluebooks, entry[:verb])
177
+ next [] unless aggregate
178
+
179
+ owner = owner_for_verb(bluebooks, entry[:verb]) || aggregate
180
+
143
181
  command.mutations.select { |m| RECOMPUTABLE_MUTATION_OPS.include?(m.op) }.filter_map do |mutation|
144
- expected = recompute_mutation(mutation, entry[:before][mutation.target], entry[:args], entry[:before])
182
+ expected = recompute_mutation(mutation, entry[:before][mutation.target], entry[:args], entry[:before],
183
+ aggregate, command, owner)
145
184
  next if expected == :unrecomputable
146
185
 
147
186
  actual = entry[:after][mutation.target]
@@ -155,31 +194,167 @@ module Hecks
155
194
  offenders.empty? || offenders.join("; ")
156
195
  end
157
196
 
158
- def recompute_mutation(mutation, current, args, before_scope)
197
+ # `command_for_verb`'s own root-aggregate half (Guards)
198
+ # re-derived independently rather than read off `entry[:domain]`/
199
+ # `entry[:aggregate]` (present on a REAL `build_mutation_trace`
200
+ # entry, but not on every hand-built fixture this property is
201
+ # tested against) so this works from `entry[:verb]` alone, the
202
+ # one field every entry always carries. BUG#5's fix needs the
203
+ # ROOT aggregate specifically — `Value.for_attribute` resolves a
204
+ # value-object TYPE against the root's own namespace only, the
205
+ # same reason `EntityElement#locate_chain` threads `root_aggregate`
206
+ # through every hop separately from each hop's own `owner`.
207
+ def aggregate_for_verb(bluebooks, verb)
208
+ domain_name, aggregate_name, = Naming.split_verb(verb)
209
+ return nil unless domain_name
210
+
211
+ bluebooks[domain_name]&.aggregate(aggregate_name)
212
+ end
213
+
214
+ # `mutation.target`'s own DECLARING construct — the root
215
+ # aggregate for an aggregate-owned command (`AddSlot`'s own
216
+ # `:slots`), or the entity a dot-shaped command belongs to
217
+ # (`Board.AddCard`'s own `:cards`, declared on `Board`, not on
218
+ # `Workspace`) — needed only by BUG#12's own recompute-side fix
219
+ # (`recompute_append`, below): `entity.attribute(mutation.target)`
220
+ # has to be asked of whichever construct actually declares it,
221
+ # never the root aggregate unconditionally, the same distinction
222
+ # `EntityElement#locate_chain` draws between `root_aggregate` and
223
+ # each hop's own `owner`. Re-derived independently from
224
+ # `entry[:verb]` alone, the same reasoning `aggregate_for_verb`'s
225
+ # own comment gives.
226
+ def owner_for_verb(bluebooks, verb)
227
+ domain_name, aggregate_name, command_path = Naming.split_verb(verb)
228
+ return nil unless command_path
229
+
230
+ aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
231
+ return nil unless aggregate
232
+ return aggregate unless command_path.include?(".")
233
+
234
+ entity_name, = command_path.split(".", 2)
235
+ aggregate.entities.find { |candidate| candidate.hecks_name == entity_name }
236
+ end
237
+
238
+ def recompute_mutation(mutation, current, args, before_scope, aggregate, command, owner = aggregate)
159
239
  case mutation.op
160
- when :append then recompute_append(current, mutation.source, before_scope, args)
240
+ when :append
241
+ recompute_append(current, mutation.source, before_scope, args, aggregate, command, owner, mutation.target)
161
242
  when :remove then recompute_remove(current, mutation.source, args)
162
243
  when :multiply then recompute_multiply(current, resolve_mutation_source(mutation.source, args))
163
244
  when :clamp then recompute_clamp(current, mutation.source)
164
245
  end
165
246
  end
166
247
 
167
- # `MutationApplier#appended`'s own value-object branch (never the
168
- # entity_element branch see #build_mutation_trace's own comment
169
- # on why an entity-dispatched command's own mutations never reach
170
- # it), reproduced: the field map resolved the SAME two-tier way
248
+ # `EntityElement#appended_to_element`'s own field-mapping half,
249
+ # reproduced: the field map resolved the SAME two-tier way
171
250
  # (`MutationApplier#resolve_append_source` — a caller-supplied
172
- # arg, or the entity's own current field), then appended.
173
- def recompute_append(current, source_map, before_scope, args)
174
- fields = source_map.transform_values { |source| resolve_mutation_append_field(source, before_scope, args) }
251
+ # arg, or the entity's own current field), then appended. (An
252
+ # entity-dispatched command's own mutations DO reach here —
253
+ # `#build_mutation_trace`'s own comment describing them as never
254
+ # reaching "the entity_element branch" means `MutationApplier#
255
+ # appended`'s own AGGREGATE-level entity_element fallback
256
+ # specifically, which really is unreached from here; entity-owned
257
+ # append dispatches go through `EntityElement#appended_to_element`
258
+ # instead, and DO reach this method.)
259
+ #
260
+ # BUG#5 — an entity-owned `:append` whose target field is itself
261
+ # value-object-typed (`Board.AddCard`'s own `sets :cards, append:
262
+ # { sequence: :sequence }`, `CardSequence`-typed). A caller-
263
+ # supplied arg reaches the REAL applier (`EntityElement#
264
+ # appended_to_element`) already coerced: `Interpreting#
265
+ # coerce_declared_arguments` runs `Value.for_attribute` over
266
+ # EVERY arg the acting command itself declares, BEFORE dispatch
267
+ # ever reaches a mutation applier at all — independent of, and
268
+ # earlier than, anything `appended_to_element`'s own value_object
269
+ # check does. `before_scope[source]` (the entity's OWN current
270
+ # field) needs no such re-coercion here: it's already the
271
+ # MATERIALIZED shape `build_mutation_trace` snapshotted it in
272
+ # (`Value.materialize`, same as `entry[:after]`), not a raw value
273
+ # sitting behind a live `Value`.
274
+ #
275
+ # BUG#12 — `owner`/`target` (new here) let this ALSO reproduce
276
+ # `EntityElement#fill_declared_defaults`'s own entity-nested-in-
277
+ # entity fallback (`appended_to_element`'s `else` branch, when
278
+ # the appended element is itself an entity — `Card`, nested
279
+ # inside `Board` — not a value object): `owner.attribute(target)
280
+ # &.type` names the appended element's own type; when that names
281
+ # an entity of `aggregate` rather than a value object, every one
282
+ # of ITS OWN declared attributes `fields` doesn't already hold
283
+ # gets `Instance.default_for`'s own default — reused, not
284
+ # reimplemented, for the identical "never agree with itself"
285
+ # reason BUG#5's own coercion re-derivation above already gives:
286
+ # `Instance.default_for` is pre-existing, independently-tested
287
+ # machinery (an ordinary aggregate's own creation already runs
288
+ # through it via `Instance.defaults`), not the NEW glue
289
+ # (`fill_declared_defaults` itself) this property exists to
290
+ # catch a drift in.
291
+ def recompute_append(current, source_map, before_scope, args, aggregate, command, owner = aggregate, target = nil)
292
+ fields = source_map.transform_values do |source|
293
+ resolve_mutation_append_field(source, before_scope, args, aggregate, command)
294
+ end
295
+ fill_recompute_declared_defaults(aggregate, owner, target, fields)
175
296
  Array(current) + [symbolize_deep(fields)]
176
297
  end
177
298
 
178
- def resolve_mutation_append_field(source, before_scope, args)
299
+ # BUG#12's own recompute-side half — see `#recompute_append`'s
300
+ # own comment above for why this exists and why it reuses
301
+ # `Instance.default_for` rather than calling `EntityElement#
302
+ # fill_declared_defaults` again. A no-op whenever `target` names
303
+ # no attribute at all (every RECOMPUTABLE_MUTATION_OPS caller but
304
+ # `:append` passes no `target`) or `target`'s own declared type
305
+ # isn't an entity nested directly under `owner` (a value object,
306
+ # or nothing declared at all — `owner.attribute` answering `nil`
307
+ # for a target the DSL itself would already have refused at
308
+ # build time). `owner.entities`, NOT `aggregate.entities` — a
309
+ # piece nested inside a piece is a child of the OWNING entity
310
+ # (`Card` is `Board.entities`, never `Workspace.entities`), the
311
+ # same distinction `EntityElement#appended_to_element`'s own fix
312
+ # draws.
313
+ def fill_recompute_declared_defaults(aggregate, owner, target, fields)
314
+ return fields unless target
315
+
316
+ element_type = owner&.attribute(target)&.type
317
+ entity = element_type && owner.entities.find { |piece| piece.hecks_name == element_type.to_s }
318
+ return fields unless entity
319
+
320
+ entity.attributes.each do |attribute|
321
+ next if fields.key?(attribute.name)
322
+
323
+ fields[attribute.name] = attribute.list? ? [] : Runtime::Instance.default_for(aggregate, attribute)
324
+ end
325
+ fields
326
+ end
327
+
328
+ def resolve_mutation_append_field(source, before_scope, args, aggregate, command)
179
329
  return source unless source.is_a?(Symbol)
180
- return args[source] if args.key?(source)
330
+ return before_scope[source] unless args.key?(source)
331
+
332
+ coerce_recompute_append_arg(aggregate, command, source, args[source])
333
+ end
334
+
335
+ # `Interpreting#coerce_declared_arguments`'s own coercion,
336
+ # reproduced independently (never calling it again, the same
337
+ # "never agree with itself" rule this whole module's header
338
+ # comment gives) — a raw arg is coerced ONLY when its own name
339
+ # (`source`) is one of the ACTING COMMAND's own declared
340
+ # attributes, exactly the condition that method checks before a
341
+ # real dispatch ever coerces it either. `command.attribute(source)`
342
+ # answering `nil` (a source that names no declared attribute —
343
+ # never possible for `coerce_declared_arguments` to have touched
344
+ # it in the real dispatch either) leaves `raw` exactly as it
345
+ # arrived, the same as every bare-scalar append this already
346
+ # handled correctly before BUG#5's fix.
347
+ #
348
+ # `Value.materialize`d immediately after coercing — matching the
349
+ # plain-data shape `entry[:before]`/`entry[:after]` already carry
350
+ # throughout this whole property, so the eventual `symbolize_deep`
351
+ # comparison is always materialized-against-materialized, never a
352
+ # live `Value` against a Hash.
353
+ def coerce_recompute_append_arg(aggregate, command, source, raw)
354
+ attribute = command.attribute(source)
355
+ return raw unless attribute
181
356
 
182
- before_scope[source]
357
+ Runtime::Value.materialize(Runtime::Value.for_attribute(aggregate, attribute, raw, argument: true))
183
358
  end
184
359
 
185
360
  # `MutationApplier#removed`'s own value-equality match, reproduced.
@@ -138,6 +138,109 @@ module Hecks
138
138
  offenders.empty? || offenders.join("; ")
139
139
  end
140
140
 
141
+ # ANGLE-8's OWN WRITE-SIDE HALF. `authorize_scopes_or_refuses`
142
+ # (above) enforces `TenantScope.apply`'s boundary, and that
143
+ # boundary exists ONLY for queries/read models — `authorize
144
+ # policy, tenant: field` is a word `QuerySpecification::Common::
145
+ # DSL#authorize_impl` grants to `QueryBuilder`/`ReadModelBuilder`
146
+ # alone; `CommandBuilder` never includes that module, so no
147
+ # bluebook can declare it on a command at all (confirmed by
148
+ # reading the grammar directly, not inferred). A WRITE that
149
+ # carries a `reference_to` from one tenant-scoped record into
150
+ # another's is checked by NOTHING at dispatch time: `TenantScope`
151
+ # never runs for a command, and no runtime `given`/`ensures`
152
+ # anywhere in this corpus reads a cross-aggregate tenant field
153
+ # either. `qa/stress_domains/tenant_ledger` exists to give this
154
+ # property a real place to fire.
155
+ #
156
+ # THE RULE: an aggregate's own declared TENANT FIELD is whichever
157
+ # field one of ITS OWN queries names in `authorize policy, tenant:
158
+ # :field` — the exact same declaration `authorize_scopes_or_
159
+ # refuses` reads off a query above, reused here to name a field
160
+ # on the AGGREGATE ITSELF that stores the tenant it belongs to.
161
+ # For every STORED record (`history[:instances]` — a refused
162
+ # dispatch never writes one, so "a refusal is correct behaviour,
163
+ # not a finding" holds by construction, the same way `history
164
+ # [:instances]` already guarantees this for `stored_records_
165
+ # satisfy_declared_invariants`) whose own aggregate declares a
166
+ # tenant field, walk every `reference_to`-typed attribute it
167
+ # carries (`Bluebook::Reference` — "a reference IS the id",
168
+ # value/coercion.rb's own header, so the stored value is always a
169
+ # plain id, never a nested payload) pointing at ANOTHER aggregate
170
+ # that ALSO declares a tenant field: if the referenced record's
171
+ # own tenant value disagrees with the referencing record's own
172
+ # tenant value, the write crossed a tenant boundary and nothing
173
+ # refused it — a finding.
174
+ #
175
+ # A DANGLING/UNRESOLVABLE REFERENCE IS SKIPPED — a different,
176
+ # existence-shaped property's claim, not this one's (the same
177
+ # "inconclusive, not a claimed pass" restraint `lifecycle_guard_
178
+ # and_given_violations_are_refused` already documents for a
179
+ # differently-shaped case). Comparison goes through `Ports::
180
+ # Query::InMemory.comparable` (the SAME normalization `authorize_
181
+ # scopes_or_refuses` already applies to a query row's own tenant
182
+ # field, just above) rather than `Runtime::Value#==` directly —
183
+ # two single-attribute value objects with the SAME scalar but
184
+ # DIFFERENT declared names (`LedgerRegion`/`TransferRegion`, this
185
+ # domain's own pair — a value object is always declared inside
186
+ # the aggregate that owns it, so two independently tenant-scoped
187
+ # aggregates can never share one) compare UNEQUAL under `Value#==`
188
+ # (`type_name` is part of that equality) despite meaning the
189
+ # identical tenant, which would make every same-tenant write a
190
+ # false positive.
191
+ # rubocop:disable-next Metrics/CyclomaticComplexity
192
+ # rubocop:disable-next Metrics/PerceivedComplexity
193
+ def commands_respect_tenant_scope(history)
194
+ bluebooks = history.fetch(:bluebooks)
195
+ instances = history.fetch(:instances)
196
+
197
+ offenders = instances.flat_map do |key, state|
198
+ domain_name = key.split("::").first
199
+ aggregate_name = key.split("::").last.split("#").first
200
+ aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
201
+ next [] unless aggregate
202
+
203
+ own_tenant_field = tenant_field_for(aggregate)
204
+ next [] unless own_tenant_field && state.key?(own_tenant_field)
205
+
206
+ own_tenant = Ports::Query::InMemory.comparable(state[own_tenant_field])
207
+
208
+ aggregate.attributes.filter_map do |attribute|
209
+ next unless attribute.type.is_a?(Bluebook::Reference)
210
+
211
+ target = bluebooks[domain_name]&.aggregate(attribute.type.target_name)
212
+ target_tenant_field = target && tenant_field_for(target)
213
+ next unless target_tenant_field
214
+
215
+ target_id = state[attribute.name]
216
+ next unless target_id
217
+
218
+ target_state = instances["#{domain_name}::#{target.name}##{target_id}"]
219
+ next unless target_state&.key?(target_tenant_field)
220
+
221
+ target_tenant = Ports::Query::InMemory.comparable(target_state[target_tenant_field])
222
+ next if target_tenant == own_tenant
223
+
224
+ "#{key} (#{own_tenant_field}: #{own_tenant.inspect}) references #{attribute.name}: #{target_id.inspect}, " \
225
+ "but #{domain_name}::#{target.name}##{target_id} carries #{target_tenant_field}: " \
226
+ "#{target_tenant.inspect} — a cross-tenant write nothing refused"
227
+ end
228
+ end
229
+
230
+ offenders.empty? || offenders.join("; ")
231
+ end
232
+
233
+ # THE FIELD AN AGGREGATE'S OWN QUERY NAMES AS TENANT-SCOPING —
234
+ # shared by `commands_respect_tenant_scope` above for both sides
235
+ # of a `reference_to`. `nil` for an aggregate with no `authorize
236
+ # ..., tenant:` on any of its own queries — not every aggregate
237
+ # is tenant-scoped, and one that isn't has nothing for this
238
+ # property to check either side of.
239
+ def tenant_field_for(aggregate)
240
+ authorization = aggregate.queries.filter_map(&:authorization).find(&:tenant)
241
+ authorization&.tenant&.to_sym
242
+ end
243
+
141
244
  # A DECLARED PROCESS MANAGER'S OWN COMMAND — `command.hecks_name`,
142
245
  # or an entity's own if the verb's second component is itself
143
246
  # dotted (`Aggregate.Entity.Command`, the same two shapes
@@ -88,7 +88,40 @@ module Hecks
88
88
  first = Replay.call(domain_path, steps, adapter: adapter)
89
89
  second = Replay.call(domain_path, steps, adapter: adapter)
90
90
 
91
- comparable = ->(history) { history.except(:bluebook, :bluebooks) }
91
+ # `:event_uid`/`:delivery_id` `Runtime::Outbox::Fanout#rows_for`'s
92
+ # OWN `SecureRandom.uuid`, minted fresh per enqueue specifically so
93
+ # it stays OFF `Event#to_h` (that file's own comment: the domain's
94
+ # own events stay mintless; this is Ruby-only relay bookkeeping,
95
+ # never part of what a replay claims the DOMAIN produced) — so two
96
+ # otherwise-identical replays legitimately carry two different
97
+ # uuids here, the same reason `:bluebook`/`:bluebooks` (live
98
+ # object identities, not values) are excluded below.
99
+ #
100
+ # `row[:event][:occurred_at]` — the outbox row's own `event:` field
101
+ # is `Outbox.serialize_event`'s `event.to_h.merge(correlation:...)`,
102
+ # the FULL `Event#to_h`, unlike `history[:events]` (this file's own
103
+ # `events = runtime.events.map { {name:, aggregate:, id:, payload:}
104
+ # }`, above) which has ALWAYS deliberately left `occurred_at` OFF
105
+ # the compared surface for exactly this reason: a wall-clock read,
106
+ # never reproducible byte-for-byte between two independent
107
+ # replaying processes a second apart. Stripped here the same way,
108
+ # for the one place it newly reappears.
109
+ #
110
+ # Both stripped from each `outbox_traces` row before comparing
111
+ # rather than dropping `outbox_traces` wholesale: everything else
112
+ # on a row (status, consumer, kind, the event's own name/
113
+ # aggregate/id/payload) IS reproducible from the steps alone and
114
+ # stays checked.
115
+ strip_outbox_nondeterminism = lambda do |history|
116
+ traces = Array(history[:outbox_traces]).map do |trace|
117
+ trace.merge(rows: trace[:rows].map do |row|
118
+ row.except(:event_uid, :delivery_id).merge(event: row[:event].except(:occurred_at))
119
+ end)
120
+ end
121
+ history.merge(outbox_traces: traces)
122
+ end
123
+
124
+ comparable = ->(history) { strip_outbox_nondeterminism.call(history).except(:bluebook, :bluebooks) }
92
125
  return true if comparable.call(first) == comparable.call(second)
93
126
 
94
127
  "two replays of the same #{steps.length} steps produced different histories"
@@ -0,0 +1,142 @@
1
+ require_relative "../../bluebook/expression/evaluator"
2
+
3
+ module Hecks
4
+ module Fuzzing
5
+ module Properties
6
+ # THE TRANSACTIONAL OUTBOX'S OWN CONTRACT (`Runtime::Outbox`, that
7
+ # file's own header), held to the history a replay actually produced
8
+ # rather than trusted. Two facts, each independently checkable from
9
+ # `history[:outbox_traces]` (`Replay#call`'s own before/after
10
+ # capture, one entry per step whose dispatch enqueued at least one
11
+ # row):
12
+ #
13
+ # 1. "DELIVERY IS INLINE BY DEFAULT" — a row THIS replay's own
14
+ # dispatch enqueued must not still be `pending`/`claimed` once
15
+ # that SAME call returns (nothing here ever simulates a
16
+ # crash), and must not be `failed` either — `deliver_row`'s
17
+ # own rescue only reaches `failed` for a genuine defect in the
18
+ # RELAY's own consumer resolution (a row naming a policy/
19
+ # process_manager `run_consumer`'s own independent registry
20
+ # lookup cannot find — `WiringError`), never an ordinary
21
+ # domain refusal (`PolicyInterpreter#deliver`/`SagaInterpreter#
22
+ # advance` both rescue those THEMSELVES, recording `delivered:
23
+ # false` on the reaction/saga log and letting `run_consumer`
24
+ # return normally). Both checked for every row, `saga:` and
25
+ # `policy:` alike.
26
+ #
27
+ # 2. A `policy:` ROW SPECIFICALLY — `PolicyInterpreter#deliver`
28
+ # returns `nil` (no `reaction_log` entry appended at all)
29
+ # EXACTLY when its own `where` (or, for a fan-out policy, the
30
+ # SAME `where`, gating the whole `for_each`) does not hold;
31
+ # every OTHER outcome (delivered, refused, a defect,
32
+ # reaction-depth-reached) is still a non-nil record `#react`
33
+ # appends. So a `delivered` policy row with NO matching
34
+ # `reaction_log` entry is legitimate ONLY when that policy's
35
+ # own `where`, independently RE-EVALUATED here against the
36
+ # row's own recorded event, genuinely does not hold. A
37
+ # `for_each` policy's own fan-out correctness (how MANY rows
38
+ # it should have dispatched to) is `fanout_dispatches_once_
39
+ # per_matching_row`'s job, not this one's.
40
+ #
41
+ # A `saga:` ROW HAS NO EQUIVALENT SECOND CHECK, DELIBERATELY — this
42
+ # was the first shape this property shipped with, and it was WRONG,
43
+ # caught live against `examples/banking` before this comment
44
+ # existed: `Fanout.sagas`' own `listens?` (starts_on/ends_on/
45
+ # handler_for matching the event NAME alone) says nothing about
46
+ # whether a CORRELATION resolves or a LIVE INSTANCE exists, and
47
+ # `begin_saga`/`end_saga` (saga_interpreter.rb) both have silent,
48
+ # perfectly ordinary no-op paths that append NOTHING to `saga_log`
49
+ # — `begin_saga` when an instance under that correlation already
50
+ # exists, `end_saga` when NO live instance exists to end (an
51
+ # `AccountOpened` fired by opening an account directly, bypassing
52
+ # the onboarding flow whose `ends_on` names that same event,
53
+ # reproduces this exactly: `Fanout.listens?` enqueues the row
54
+ # because the event NAME matches `ends_on`, `end_saga` finds
55
+ # nothing under that correlation to delete, and neither logs a
56
+ # word). A `saga:` row draining to `delivered` with zero matching
57
+ # `saga_log` entries is therefore NOT a finding — only check 1
58
+ # applies to it.
59
+ #
60
+ # NOT A GRAMMAR CONSTRUCT — `FEATURE_COVERAGE`'s own `dry_runs_
61
+ # leave_no_trace` precedent: the outbox is a runtime door
62
+ # (`Runtime::Outbox`), not a word a bluebook declares, so there is
63
+ # no feature string here to claim.
64
+ module Outbox
65
+ def outbox_rows_match_reactions(history)
66
+ bluebooks = history.fetch(:bluebooks, {})
67
+
68
+ offenders = Array(history[:outbox_traces]).flat_map do |trace|
69
+ trace[:rows].flat_map { |row| outbox_row_offenders(row, trace, bluebooks) }
70
+ end
71
+
72
+ offenders.empty? || offenders.join("; ")
73
+ end
74
+
75
+ def outbox_row_offenders(row, trace, bluebooks)
76
+ on = row.dig(:event, :name)
77
+
78
+ case row[:status]
79
+ when "pending", "claimed"
80
+ ["outbox row #{row[:delivery_id]} (#{row[:consumer]} on #{on}) never drained inline — status stayed " \
81
+ "#{row[:status].inspect} though delivery is inline by contract (Runtime::Outbox's own header)"]
82
+ when "failed"
83
+ ["outbox row #{row[:delivery_id]} (#{row[:consumer]} on #{on}) failed to deliver: #{row[:error]} — " \
84
+ "a domain refusal never reaches this far; a failed row names a defect in the relay's own consumer " \
85
+ "resolution"]
86
+ when "delivered"
87
+ outbox_delivered_policy_offenders(row, on, trace, bluebooks)
88
+ else
89
+ []
90
+ end
91
+ end
92
+
93
+ # See this file's own header for why a `saga:` row is exempt: its
94
+ # own `listens?` gives no such guarantee, unlike a policy's single,
95
+ # deterministic `where` gate.
96
+ def outbox_delivered_policy_offenders(row, on, trace, bluebooks)
97
+ kind, fqn = row[:consumer].to_s.split(":", 2)
98
+ return [] unless kind == "policy"
99
+
100
+ home, name = fqn.to_s.split("::", 2)
101
+ return [] if trace[:reactions].any? { |entry| entry[:policy] == name && entry[:on] == on }
102
+
103
+ policy = bluebooks[home]&.policies&.find { |candidate| candidate.name == name }
104
+ return [] unless policy # nothing declared under this name — inconclusive, not a claimed mismatch
105
+ return [] if policy.fans_out? # fan-out row count is fanout_dispatches_once_per_matching_row's job
106
+
107
+ held = independently_re_evaluate_policy_where(policy, row[:event])
108
+ return [] if held != true # false, or inconclusive (the where itself raised) — never a claimed mismatch
109
+
110
+ ["outbox row #{row[:delivery_id]} (#{row[:consumer]} on #{on}) drained as delivered, but no matching " \
111
+ "reaction_log entry exists and the policy's own where clause independently re-evaluates true — " \
112
+ "PolicyInterpreter#deliver only ever returns nil (no reaction_log entry) when where does not hold"]
113
+ end
114
+
115
+ # `PolicyInterpreter#where_holds?`'s own two branches, reproduced —
116
+ # never calling that method again, which would only ever agree
117
+ # with itself (the same rule `resolve_dispatch_binding`'s own
118
+ # comment states). `Evaluator.call` (the raw-string entry, parsed
119
+ # and cached — never `call_rule`, which needs the policy's own
120
+ # BUILD-TIME `where_rule` AST, an object this history has no
121
+ # reason to carry) is the exact same call `Replay#fan_out_finding`
122
+ # already makes for the identical fact one property over
123
+ # (`policy.where.to_s.empty? || Evaluator.call(policy.where, {},
124
+ # payload)`), reused rather than re-derived a second, slightly
125
+ # different way. `rescue`d to `nil`, not `false`: a where clause
126
+ # that cannot be re-evaluated from the row's own recorded payload
127
+ # alone is INCONCLUSIVE, not proof either way — the same "never a
128
+ # claimed pass or a claimed mismatch from a resolution this replay
129
+ # cannot actually reproduce" discipline `build_guard_check`'s own
130
+ # rescue clause already follows.
131
+ def independently_re_evaluate_policy_where(policy, event)
132
+ return true if policy.where.to_s.empty?
133
+
134
+ payload = (event[:payload] || {}).transform_keys(&:to_sym)
135
+ Bluebook::Expression::Evaluator.call(policy.where, {}, payload)
136
+ rescue StandardError
137
+ nil
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -9,6 +9,8 @@ require_relative "properties/querying"
9
9
  require_relative "properties/guards"
10
10
  require_relative "properties/dispatch_and_mutations"
11
11
  require_relative "properties/invariants_and_aggregation"
12
+ require_relative "properties/corrections"
13
+ require_relative "properties/outbox"
12
14
 
13
15
  module Hecks
14
16
  module Fuzzing
@@ -55,7 +57,10 @@ module Hecks
55
57
  extend Querying
56
58
  extend Guards
57
59
  extend DispatchAndMutations
60
+ extend DryRuns
58
61
  extend InvariantsAndAggregation
62
+ extend Corrections
63
+ extend Outbox
59
64
 
60
65
  module_function
61
66
 
@@ -96,7 +101,19 @@ module Hecks
96
101
  fanout_dispatches_once_per_matching_row: %w[Policy#for_each Policy#where],
97
102
  aggregation_matches_recompute: %w[ReadModel#count ReadModel#median_field],
98
103
  stored_records_satisfy_declared_invariants: %w[Aggregate#invariants Entity#invariants],
99
- group_by_matches_recompute: %w[ReadModel#group_by]
104
+ group_by_matches_recompute: %w[ReadModel#group_by],
105
+ # A RUNTIME DOOR, NOT A GRAMMAR CONSTRUCT — `Dispatcher#dry_run?`
106
+ # is something an application asks of a booted domain, not a
107
+ # word a bluebook can declare, so there is no feature string
108
+ # for it to claim. Listed (empty) rather than omitted so the
109
+ # discipline this table states — every property names what it
110
+ # is answerable for — has no silent exception.
111
+ dry_runs_leave_no_trace: [],
112
+ # ANOTHER RUNTIME DOOR, NOT A GRAMMAR CONSTRUCT — same reasoning
113
+ # as dry_runs_leave_no_trace right above: `Runtime::Outbox` is
114
+ # something a persistence adapter provides underneath a booted
115
+ # domain, never a word a bluebook declares.
116
+ outbox_rows_match_reactions: []
100
117
  }.freeze
101
118
 
102
119
  # FEATURES A REPLAY PROPERTY COULD NEVER CATCH VIOLATED, because the
@@ -205,8 +222,12 @@ module Hecks
205
222
  paging_offset_partitions_correctly: paging_offset_partitions_correctly(history),
206
223
  lifecycle_guard_and_given_violations_are_refused: lifecycle_guard_and_given_violations_are_refused(history),
207
224
  authorize_scopes_or_refuses: authorize_scopes_or_refuses(history),
225
+ commands_respect_tenant_scope: commands_respect_tenant_scope(history),
208
226
  dispatch_binding_fidelity: dispatch_binding_fidelity(history),
209
- mutations_match_recompute: mutations_match_recompute(history) }
227
+ mutations_match_recompute: mutations_match_recompute(history),
228
+ dry_runs_leave_no_trace: dry_runs_leave_no_trace(history),
229
+ corrections_reference_an_emitted_event: corrections_reference_an_emitted_event(history),
230
+ outbox_rows_match_reactions: outbox_rows_match_reactions(history) }
210
231
  end
211
232
  end
212
233
  end