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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -115
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- 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
|
-
|
|
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
|
|
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
|
-
# `
|
|
168
|
-
#
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|