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,526 @@
1
+ require_relative "../invalid_value_generator"
2
+ require_relative "../value_generator"
3
+ require_relative "../../runtime/value"
4
+
5
+ module Hecks
6
+ module Fuzzing
7
+ class SequenceGenerator
8
+ # THE ARGUMENT SHAPES THE RUBY/RUST DIVERGENCES WERE ACTUALLY FOUND
9
+ # THROUGH, injected on purpose, on every domain.
10
+ #
11
+ # Ten bugs in one QA session (BUG#7–#16, `QualityControl`'s own
12
+ # ledger) clustered in a handful of mechanisms — and several were
13
+ # only ever reachable because ONE domain happened to declare the
14
+ # argument that tripped them: `Roster::Roster.Mark`'s own `to:`
15
+ # collided with the dispatcher's routing `to:` (BUG#7), a present-
16
+ # but-null `to:` was misread as a routing envelope (BUG#16), a blank
17
+ # creating identity minted a phantom aggregate in Rust (BUG#15), a
18
+ # single-field closed-set argument offered as bare `null` refused
19
+ # with different KINDS on the two engines (BUG#14). No other domain
20
+ # in the rotation could ever have found any of those, because
21
+ # nothing generated ever produced the shape. This module produces
22
+ # them — for a configurable fraction of command steps, chosen and
23
+ # parameterised from the SAME seeded RNG the rest of the generator
24
+ # already draws from, so `generate(domain, seed:, steps:,
25
+ # adversarial:)` stays exactly as reproducible per seed as it was.
26
+ #
27
+ # OPT-IN, BY CONSTRUCTION. `adversarial: 0.0` (the default) returns
28
+ # before drawing a single random number, so every pinned seed in
29
+ # spec/fuzzing, spec/rust_conformance_fuzz_spec.rb, bin/fuzz and
30
+ # bin/generate produces byte-for-byte what it produced before this
31
+ # module existed. `bin/qa_sweep` turns it on (reading
32
+ # `QualityControlDials::ADVERSARIAL_FRACTION`) — that script is the
33
+ # one place a mutated step's own divergence is a FINDING rather than
34
+ # a red CI gate.
35
+ #
36
+ # ONE MUTATION PER STEP, applied in `StepBuilder#build_command_step`
37
+ # after the arguments and identity are built and BEFORE the step's
38
+ # own inline dispatch — so the generator's own `known_ids` tracking
39
+ # sees what really happened, and so the returned step's `args` ARE
40
+ # the mutated bytes both `Fuzzing::Replay` (Ruby) and the compiled
41
+ # conformance binary (Rust) later receive. The step carries an
42
+ # `"adversarial"` key naming what was done (`mutation`, the bug
43
+ # class it exercises, and the exact sub-shape), which `bin/qa_sweep`
44
+ # prints on a FOUND SOMETHING report so the agent logging the Bug
45
+ # can name the mechanism, not guess it. Both replay paths ignore
46
+ # the key: `Replay.call` reads only verb/query/dry_run/args/role,
47
+ # and `kernel/cli.rs` reads step keys by name.
48
+ module Adversary
49
+ KINDS = %i[
50
+ routing_key
51
+ blank_identity
52
+ null_value_object
53
+ duplicate_entity_identity
54
+ omit_mapped_argument
55
+ refusal_precedence
56
+ ].freeze
57
+
58
+ # BUG#7/#16/#8 — `to`/`with` are `Dispatcher#dispatch`'s own
59
+ # routing keywords (Ruby's kwarg binding steals them from a flat
60
+ # args hash; Rust's `CommandInvocation::from_json` reads the same
61
+ # two names off the same object), `id` is the untyped identity
62
+ # fallback `ArgumentGate#refuse_unknown_arguments` exempts. Three
63
+ # shapes each: bare null (BUG#16), a scalar (BUG#7's out-of-range
64
+ # Integer among them), and the routing-shaped object.
65
+ ROUTING_KEYS = %w[to with id].freeze
66
+ ROUTING_SHAPES = %w[null scalar route].freeze
67
+
68
+ # BUG#15 — a creating command's own identity part, blank three ways.
69
+ BLANK_SHAPES = %w[empty whitespace null].freeze
70
+
71
+ # BUG#14 — a single-field value object as bare `null` and as `{}`.
72
+ VALUE_OBJECT_SHAPES = %w[null empty_object].freeze
73
+
74
+ # BUG#7/#8/#14's class — an unknown key, a type-mismatched declared
75
+ # key and an absent required key in the SAME step, so both engines
76
+ # have to pick which refusal wins; and each pair, so the ordering
77
+ # of any two is observable on a command too small for all three.
78
+ #
79
+ # THE SECOND ROW REACHES FURTHER DOWN `DISPATCH_ORDER`
80
+ # (lib/hecks/vocabulary.rb): `nonexistent` addresses an id nothing
81
+ # holds (a `hydrate`-stage NotFound), `lifecycle` aims a
82
+ # transition-guarded command at a real record (an
83
+ # `admissible_transition`-stage refusal, IF the record's state
84
+ # refuses it), `role` binds a caller whose role the command does
85
+ # not name (the `refuse_role_mismatch` stage no generated step
86
+ # had ever reached). Paired with an argument-stage fault each,
87
+ # so the ordering of an EARLY stage against a LATE one is
88
+ # observable — BUG#13 (ledger_ordering's own NOTES.md) was
89
+ # exactly an argument-invariant-vs-entity-existence ordering
90
+ # split, and nothing generated had ever asked the question on
91
+ # purpose.
92
+ PRECEDENCE_SHAPES = %w[
93
+ unknown+mismatch+absent unknown+absent unknown+mismatch mismatch+absent
94
+ nonexistent+mismatch nonexistent+unknown lifecycle+mismatch role+absent role+nonexistent
95
+ ].freeze
96
+
97
+ # ITEM 2 OF THE DETECTION PLAN (ANGLE-5) — A DRAWN CALLER ON A
98
+ # ROLE-GATED COMMAND. `refuse_role_mismatch` is a `DISPATCH_ORDER`
99
+ # step both engines implement (`command_rules/authorization.rb`,
100
+ # `rust/src/kernel/repository.rs check_role`) and both replay doors
101
+ # already read (`Fuzzing::Replay` binds `Hecks.as_caller` from a
102
+ # step's `role:`/`actor_id:`; `kernel/cli.rs` reads the same two
103
+ # keys) — yet no generated step ever carried either key, so the
104
+ # check was dormant on every sweep ever run. Five shapes:
105
+ # matching the command's own role — the string fallback
106
+ # authorizes it on both engines
107
+ # mismatched another declared role (or none the domain
108
+ # knows) — Unauthorized on both
109
+ # absent_on_gated no caller at all on a gated command — the
110
+ # unchecked default, recorded so the step reads
111
+ # as a deliberate control, not an omission
112
+ # actor_known the role PLUS an actor this same sequence
113
+ # already granted it to (`Governance::
114
+ # RoleAssignment.Assign` succeeded earlier) —
115
+ # the real `holds_role?` lookup, both sides
116
+ # actor_unknown the role plus an actor nothing granted —
117
+ # `holds_role?` must refuse on both
118
+ # A SEPARATE LAYER FROM `KINDS`, with its own probability
119
+ # (`role_draw:` — `QualityControlDials::ROLE_DRAW_PROBABILITY`):
120
+ # a caller composes with any argument mutation above rather than
121
+ # competing with it for the one-mutation-per-step slot, and draws
122
+ # nothing from the RNG when off, so every pinned seed is
123
+ # byte-identical to before it existed.
124
+ CALLER_SHAPES = %w[matching mismatched absent_on_gated actor_known actor_unknown].freeze
125
+ GRANT_VERB = "Governance::RoleAssignment.Assign".freeze
126
+ UNKNOWN_ROLE = "Nobody the domain names".freeze
127
+
128
+ # BUG#11 — an entity command two or more hops deep. Not a mutation
129
+ # of arguments but a PREFERENCE (picker.rb weights these up when
130
+ # adversarial) plus an addressing coin: flat one-head-per-hop args
131
+ # (what every other generated entity step uses) or the routed
132
+ # `to: { aggregate:, entities: [...] }` envelope BUG#11's own fix
133
+ # was scoped to. The step's metadata reports the depth reached
134
+ # so a depth-3 domain is visibly exercised there.
135
+ DEEP_ENTITY_DEPTH = 2
136
+ DEEP_ENTITY_WEIGHT = 4
137
+
138
+ # BUG#13's mutation is only ever applicable on a step whose
139
+ # parent ALREADY holds an element this same sequence appended —
140
+ # a rare moment (an append has to have succeeded first, and most
141
+ # mutated appends are refused), so when it is on offer it is
142
+ # weighted up the same way the picker weights an unexercised
143
+ # verb: the opportunity is what is scarce, not the kind.
144
+ DUPLICATE_IDENTITY_WEIGHT = 3
145
+
146
+ private
147
+
148
+ def adversarial? = @adversarial.positive?
149
+
150
+ # `[]` — and NO RNG DRAW — when adversarial mode is off. Otherwise
151
+ # the deep-entity addressing note (every deep step, when
152
+ # adversarial), then with probability `@adversarial` exactly one
153
+ # mutation among those applicable to THIS step's own command.
154
+ def adversarial_mutations!(args, entry, catalog)
155
+ return [] unless adversarial?
156
+
157
+ mutations = []
158
+ mutations << deep_entity_addressing!(args, entry) if (entry[:chain] || []).size >= DEEP_ENTITY_DEPTH
159
+ return mutations if @random.rand >= @adversarial
160
+
161
+ applicable = KINDS.select { |kind| send(:"#{kind}_applicable?", args, entry, catalog) }
162
+ return mutations if applicable.empty?
163
+
164
+ weighted = applicable.flat_map { |kind| [kind] * (kind == :duplicate_entity_identity ? DUPLICATE_IDENTITY_WEIGHT : 1) }
165
+ mutations << send(:"apply_#{weighted.sample(random: @random)}!", args, entry, catalog)
166
+ end
167
+
168
+ # ── BUG#11: depth ≥ 2 entity command, flat or routed addressing ──
169
+
170
+ def deep_entity_addressing!(args, entry)
171
+ depth = entry[:chain].size
172
+ routed = @random.rand(2).zero?
173
+ note = { "mutation" => "deep_entity", "bug" => "BUG#11", "depth" => depth,
174
+ "addressing" => routed ? "routed" : "flat" }
175
+ return note unless routed
176
+
177
+ heads = [entry[:aggregate], *entry[:chain]].map { |construct| (construct.identified_by || :id).to_s }
178
+ scalars = heads.map { |head| ValueGenerator.scalar_of(args[head]) }
179
+ # The heads leave the flat args — this is the CLEAN routed
180
+ # caller BUG#11's own spec pins (`to: {...}, note: {...}`) —
181
+ # unless the command itself declares an attribute of that name
182
+ # (chess's `Piece.Move` declares `id` as a fact too), which
183
+ # stays because dropping it would be a different mutation.
184
+ heads.each { |head| args.delete(head) unless entry[:command].attribute(head) }
185
+ args["to"] = { "aggregate" => scalars.first, "entities" => scalars.drop(1) }
186
+ note
187
+ end
188
+
189
+ # ── BUG#7/#16/#8: an undeclared routing/identity key on flat args ──
190
+
191
+ # Not on a step already carrying a routed `to:` from
192
+ # `deep_entity_addressing!` — overwriting that envelope would
193
+ # leave the deep-entity note claiming an addressing the args no
194
+ # longer have.
195
+ def routing_key_applicable?(args, _entry, _catalog) = !args.key?("to")
196
+
197
+ def apply_routing_key!(args, entry, _catalog)
198
+ key = ROUTING_KEYS.sample(random: @random)
199
+ shape = ROUTING_SHAPES.sample(random: @random)
200
+ args[key] =
201
+ case shape
202
+ when "null" then nil
203
+ when "scalar" then routing_scalar(args, entry)
204
+ else routing_object(args, entry)
205
+ end
206
+ { "mutation" => "routing_key", "bug" => "BUG#7/#16/#8", "key" => key, "shape" => shape,
207
+ "declared" => !entry[:command].attribute(key).nil? }
208
+ end
209
+
210
+ # A real-looking id (this step's own parent, so a routed envelope
211
+ # can actually resolve), BUG#7's own out-of-range Integer, or a
212
+ # minted id nothing holds.
213
+ def routing_scalar(args, entry)
214
+ case @random.rand(3)
215
+ when 0 then parent_scalar_of(args, entry)
216
+ when 1 then ValueGenerator::INTEGER_EDGE_CASES.sample(random: @random)
217
+ else ValueGenerator.random_id(@random)
218
+ end
219
+ end
220
+
221
+ def routing_object(args, entry)
222
+ entities = (entry[:chain] || []).map { |piece| ValueGenerator.scalar_of(args[(piece.identified_by || :id).to_s]) }
223
+ # Half the time one identity too many — a depth the verb does
224
+ # not have, which `Routing.envelope`'s `entity_depth` check and
225
+ # Rust's own envelope parser must both refuse the same way.
226
+ entities << ValueGenerator.random_id(@random) if @random.rand(2).zero?
227
+ { "aggregate" => parent_scalar_of(args, entry), "entities" => entities }
228
+ end
229
+
230
+ def parent_scalar_of(args, entry)
231
+ key = (entry[:aggregate].identified_by || :id).to_s
232
+ args.key?(key) ? ValueGenerator.scalar_of(args[key]) : identity_scalar_of(entry[:aggregate], args)
233
+ end
234
+
235
+ # ── BUG#15: a blank identity part on a creating step ──────────────
236
+
237
+ def blank_identity_applicable?(args, entry, catalog) = blank_identity_targets(args, entry, catalog).any?
238
+
239
+ # The identity heads THIS step supplies itself: a creating
240
+ # aggregate command's own (every part of a composite), and an
241
+ # append's caller-supplied entity identity arguments (an entity is
242
+ # "created" by its append, and BUG#15's blank-identity question
243
+ # applies there too).
244
+ def blank_identity_targets(args, entry, catalog)
245
+ targets = []
246
+ if entry[:entity].nil? && entry[:command].creates?
247
+ aggregate = entry[:aggregate]
248
+ heads = composite_identity?(aggregate) ? aggregate.identity_heads : [aggregate.identified_by || :id]
249
+ targets.concat(heads.map(&:to_s))
250
+ end
251
+ populator = populator_for_entry(catalog, entry)
252
+ targets.concat(populator[:identity_arguments].map(&:to_s)) if populator
253
+ targets.uniq.select { |head| args.key?(head) }
254
+ end
255
+
256
+ def apply_blank_identity!(args, entry, catalog)
257
+ head = blank_identity_targets(args, entry, catalog).sample(random: @random)
258
+ shape = BLANK_SHAPES.sample(random: @random)
259
+ blank = shape == "empty" ? "" : " "
260
+ args[head] =
261
+ if shape == "null" then nil
262
+ elsif args[head].is_a?(Hash) then args[head].transform_values { blank }
263
+ else blank
264
+ end
265
+ { "mutation" => "blank_identity", "bug" => "BUG#15", "argument" => head, "shape" => shape }
266
+ end
267
+
268
+ # ── BUG#14: a single-field value object as null / {} ─────────────
269
+
270
+ def null_value_object_applicable?(args, entry, _catalog) = value_object_targets(args, entry).any?
271
+
272
+ def value_object_targets(args, entry)
273
+ aggregate = entry[:aggregate]
274
+ entry[:command].attributes.reject { |attribute| attribute.list? || attribute.reference? }
275
+ .select { |attribute| args.key?(attribute.name.to_s) }
276
+ .filter_map do |attribute|
277
+ value_object = Runtime::Value.value_object_for(aggregate, attribute.type.to_s)
278
+ [attribute, value_object] if value_object&.sole_attribute
279
+ end
280
+ end
281
+
282
+ def apply_null_value_object!(args, entry, _catalog)
283
+ targets = value_object_targets(args, entry)
284
+ closed = targets.select { |_, value_object| value_object.closed_set? }
285
+ attribute, value_object = (closed.empty? ? targets : closed).sample(random: @random)
286
+ shape = VALUE_OBJECT_SHAPES.sample(random: @random)
287
+ args[attribute.name.to_s] = shape == "null" ? nil : {}
288
+ { "mutation" => "null_value_object", "bug" => "BUG#14", "argument" => attribute.name.to_s,
289
+ "value_object" => value_object.hecks_name, "closed_set" => value_object.closed_set? == true,
290
+ "shape" => shape }
291
+ end
292
+
293
+ # ── BUG#13: an entity identity this sequence already appended ────
294
+
295
+ def duplicate_entity_identity_applicable?(args, entry, catalog) = duplicate_identity_pool(args, entry, catalog).any?
296
+
297
+ def duplicate_identity_pool(args, entry, catalog)
298
+ populator = populator_for_entry(catalog, entry)
299
+ return [] unless populator && populator[:identity_arguments].any?
300
+
301
+ @appended_identities[append_pool_key(populator, args)]
302
+ end
303
+
304
+ def apply_duplicate_entity_identity!(args, entry, catalog)
305
+ populator = populator_for_entry(catalog, entry)
306
+ tuple = duplicate_identity_pool(args, entry, catalog).sample(random: @random)
307
+ args.merge!(tuple)
308
+ { "mutation" => "duplicate_entity_identity", "bug" => "BUG#13", "entity" => populator[:entity].hecks_name,
309
+ "composite" => populator[:identity_arguments].size > 1, "identity" => tuple }
310
+ end
311
+
312
+ # ── BUG#12: a mapped/declared attribute left out of the payload ──
313
+
314
+ def omit_mapped_argument_applicable?(args, entry, _catalog) = mapped_argument_targets(args, entry).any?
315
+
316
+ # For an append: the arguments its `append:` mapping sources (the
317
+ # element's own declared fields). For a plain creating command:
318
+ # every declared non-identity attribute. Never an identity head —
319
+ # that is `blank_identity`'s question, not this one.
320
+ def mapped_argument_targets(args, entry)
321
+ command = entry[:command]
322
+ heads = identity_heads_of(entry)
323
+ mapped = command.mutations.select { |mutation| mutation.op == :append }
324
+ .flat_map { |mutation| mutation.source.values.grep(Symbol).map(&:to_s) }
325
+ if mapped.empty? && !entry.key?(:entity) && command.creates?
326
+ mapped = command.attributes.map { |attribute| attribute.name.to_s }
327
+ end
328
+ command.attributes.select do |attribute|
329
+ name = attribute.name.to_s
330
+ mapped.include?(name) && args.key?(name) && !heads.include?(name)
331
+ end
332
+ end
333
+
334
+ def apply_omit_mapped_argument!(args, entry, _catalog)
335
+ attribute = mapped_argument_targets(args, entry).sample(random: @random)
336
+ args.delete(attribute.name.to_s)
337
+ { "mutation" => "omit_mapped_argument", "bug" => "BUG#12", "argument" => attribute.name.to_s,
338
+ "optional" => attribute.optional? == true }
339
+ end
340
+
341
+ # ── BUG#7/#8/#14: unknown + mismatched + absent, in one step ─────
342
+
343
+ def refusal_precedence_applicable?(args, entry, _catalog)
344
+ precedence_shapes_for(args, entry).any?
345
+ end
346
+
347
+ # EVERY SHAPE WHOSE EVERY PART THIS STEP CAN CARRY. `nonexistent`
348
+ # and `lifecycle` need a command that ACTS on a record (a creating
349
+ # step has no addressed id to point elsewhere, and no state to be
350
+ # in) and flat addressing (`deep_entity_addressing!`'s routed `to:`
351
+ # envelope owns the ids then); `lifecycle` additionally needs a
352
+ # command some guard actually watches; `role` needs a declared
353
+ # `role` to mismatch against.
354
+ def precedence_shapes_for(args, entry)
355
+ can = {
356
+ "mismatch" => corruptible_attributes(args, entry).any?,
357
+ "absent" => droppable_required_attributes(args, entry).any?,
358
+ "unknown" => true,
359
+ "nonexistent" => acts_on_record?(args, entry),
360
+ "lifecycle" => acts_on_record?(args, entry) && transition_guarded?(entry),
361
+ "role" => !entry[:command].role.to_s.empty?
362
+ }
363
+ PRECEDENCE_SHAPES.select { |shape| shape.split("+").all? { |part| can.fetch(part) } }
364
+ end
365
+
366
+ def acts_on_record?(args, entry) = !entry[:command].creates? && !args.key?("to")
367
+
368
+ def transition_guarded?(entry)
369
+ command = entry[:command]
370
+ owner = entry.key?(:entity) ? entry[:entity] : entry[:aggregate]
371
+ return true if command.from
372
+ return false unless owner.respond_to?(:lifecycle)
373
+
374
+ owner.lifecycle&.transitions_for(command.hecks_name)&.any? || false
375
+ end
376
+
377
+ def apply_refusal_precedence!(args, entry, catalog)
378
+ corruptible = corruptible_attributes(args, entry)
379
+ droppable = droppable_required_attributes(args, entry)
380
+ wanted = precedence_shapes_for(args, entry).sample(random: @random).split("+")
381
+ detail = { "mutation" => "refusal_precedence", "bug" => "BUG#7/#8/#14" }
382
+ applied = []
383
+
384
+ if wanted.include?("absent")
385
+ dropped = droppable.sample(random: @random)
386
+ args.delete(dropped.name.to_s)
387
+ detail["absent"] = dropped.name.to_s
388
+ applied << "absent"
389
+ corruptible -= [dropped]
390
+ end
391
+ if wanted.include?("mismatch") && corruptible.any?
392
+ attribute = corruptible.sample(random: @random)
393
+ args[attribute.name.to_s] = InvalidValueGenerator.corrupt(attribute, entry[:aggregate], random: @random)
394
+ detail["mismatched"] = attribute.name.to_s
395
+ applied << "mismatch"
396
+ end
397
+ if wanted.include?("unknown")
398
+ name, value = InvalidValueGenerator.undeclared_argument(random: @random)
399
+ args[name] = value
400
+ detail["unknown"] = name
401
+ applied << "unknown"
402
+ end
403
+ apply_late_stage_parts!(wanted, args, entry, detail, applied, catalog)
404
+ # Reported as what was ACTUALLY done — a command with a single
405
+ # attribute cannot carry both a dropped and a corrupted one.
406
+ detail.merge("shape" => applied.sort.join("+"))
407
+ end
408
+
409
+ # The three parts that reach PAST the argument gate — see
410
+ # `PRECEDENCE_SHAPES`' own second row. `nonexistent` re-addresses
411
+ # the step's LAST hop (the entity element for an entity command,
412
+ # the aggregate itself otherwise) to an id nothing holds;
413
+ # `lifecycle` mutates nothing (the record's own state is what
414
+ # refuses, or doesn't) and is recorded so the pairing is visible;
415
+ # `role` parks a mismatched caller for `StepBuilder` to bind
416
+ # around this one dispatch.
417
+ def apply_late_stage_parts!(wanted, args, entry, detail, applied, catalog)
418
+ if wanted.include?("nonexistent")
419
+ piece = (entry[:chain] || []).last || entry[:aggregate]
420
+ head = (piece.identified_by || :id).to_s
421
+ args[head] = identity_shaped(piece, piece.identified_by, ValueGenerator.random_id(@random), entry[:aggregate])
422
+ detail["nonexistent"] = head
423
+ applied << "nonexistent"
424
+ end
425
+ if wanted.include?("lifecycle")
426
+ detail["lifecycle"] = entry[:command].from || "transition-guarded"
427
+ applied << "lifecycle"
428
+ end
429
+ return unless wanted.include?("role")
430
+
431
+ @precedence_caller = { "role" => other_role(entry[:command].role.to_s, catalog) }
432
+ detail["role"] = @precedence_caller["role"]
433
+ applied << "role"
434
+ end
435
+
436
+ # ── ANGLE-5: a drawn caller on a role-gated command ──────────────
437
+
438
+ def role_draw? = @role_draw.positive?
439
+
440
+ # `[caller, note]` — `caller` is the `{"role" => …, "actor_id" =>
441
+ # …}` pair (or nil for the unchecked control) `StepBuilder` binds
442
+ # around the step's one dispatch and writes onto the step itself;
443
+ # `note` rides in the step's `"adversarial"` metadata. A
444
+ # `refusal_precedence` mutation that already parked a caller for
445
+ # this step wins outright (its whole point is that pairing); an
446
+ # ungated command draws nothing (there is no role to match or
447
+ # mismatch), and with the draw off nothing is drawn at all.
448
+ def caller_draw!(entry, catalog)
449
+ if @precedence_caller
450
+ caller = @precedence_caller
451
+ @precedence_caller = nil
452
+ return [caller, nil]
453
+ end
454
+
455
+ role = entry[:command].role.to_s
456
+ return [nil, nil] if !role_draw? || role.empty? || @random.rand >= @role_draw
457
+
458
+ shapes = CALLER_SHAPES.dup
459
+ shapes.delete("actor_known") if @granted[role].empty?
460
+ shape = shapes.sample(random: @random)
461
+ caller = caller_for_shape(shape, role, catalog)
462
+ note = { "mutation" => "caller_role", "angle" => "ANGLE-5", "shape" => shape, "gated_role" => role }
463
+ [caller, caller ? note.merge(caller) : note]
464
+ end
465
+
466
+ def caller_for_shape(shape, role, catalog)
467
+ case shape
468
+ when "matching" then { "role" => role }
469
+ when "mismatched" then { "role" => other_role(role, catalog) }
470
+ when "actor_known" then { "role" => role, "actor_id" => @granted[role].sample(random: @random) }
471
+ when "actor_unknown" then { "role" => role, "actor_id" => ValueGenerator.random_id(@random) }
472
+ end
473
+ end
474
+
475
+ # Another role the domain itself declares, when it has one — a
476
+ # real "wrong hat", the more interesting mismatch — else a role no
477
+ # bluebook names at all.
478
+ def other_role(role, catalog)
479
+ others = catalog[:roles] - [role]
480
+ others.empty? ? UNKNOWN_ROLE : others.sample(random: @random)
481
+ end
482
+
483
+ def corruptible_attributes(args, entry)
484
+ entry[:command].attributes.reject(&:list?).select { |attribute| args.key?(attribute.name.to_s) }
485
+ end
486
+
487
+ def droppable_required_attributes(args, entry)
488
+ heads = identity_heads_of(entry)
489
+ entry[:command].attributes.reject(&:optional?).select do |attribute|
490
+ args.key?(attribute.name.to_s) && !heads.include?(attribute.name.to_s)
491
+ end
492
+ end
493
+
494
+ # A GRANT AIMED AT A ROLE SOME COMMAND ACTUALLY DECLARES. Left to
495
+ # `ValueGenerator`, `Assign`'s `role_name` is random text
496
+ # ("hotel"), which no command is gated on — so a granted actor
497
+ # could never satisfy `holds_role?` for anything, and the
498
+ # `actor_known` shape above would be unreachable by construction.
499
+ # With the draw on, every generated grant names one of the
500
+ # domain's own declared roles instead; with it off, nothing here
501
+ # runs (no RNG draw, no change to the args).
502
+ def steer_grant!(args, entry, catalog)
503
+ return unless role_draw? && entry[:verb] == GRANT_VERB && catalog[:roles].any?
504
+ return unless args.key?("role_name")
505
+
506
+ args["role_name"] = { "value" => catalog[:roles].sample(random: @random) }
507
+ end
508
+
509
+ # ── shared ───────────────────────────────────────────────────────
510
+
511
+ def populator_for_entry(catalog, entry)
512
+ owner = entry.key?(:entity) ? entry[:entity] : entry[:aggregate]
513
+ catalog[:populators].find { |p| p[:command].equal?(entry[:command]) && p[:owner].equal?(owner) }
514
+ end
515
+
516
+ # Every identity head this step addresses by: the aggregate's own
517
+ # (plus the untyped `id` fallback) and one per entity hop.
518
+ def identity_heads_of(entry)
519
+ heads = entry[:aggregate].identity_heads.map(&:to_s) + ["id"]
520
+ (entry[:chain] || []).each { |piece| heads.concat(piece.identity_heads.map(&:to_s)) }
521
+ heads.uniq
522
+ end
523
+ end
524
+ end
525
+ end
526
+ end
@@ -26,16 +26,7 @@ module Hecks
26
26
  queries << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{query.name}",
27
27
  query: query, aggregate: aggregate }
28
28
  end
29
- aggregate.entities.each do |entity|
30
- entity.commands.each do |command|
31
- entity_commands << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{entity.hecks_name}.#{command.hecks_name}",
32
- command: command, aggregate: aggregate, entity: entity }
33
- end
34
- entity.queries.each do |query|
35
- entity_queries << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{entity.hecks_name}.#{query.name}",
36
- query: query, aggregate: aggregate, entity: entity }
37
- end
38
- end
29
+ catalog_entities(domain_name, aggregate, entity_commands, entity_queries)
39
30
  end
40
31
 
41
32
  # THE BARE DOMAIN FORM — "Domain.report_name", no "::" — the
@@ -56,41 +47,114 @@ module Hecks
56
47
  { creating: creating, instance: instance, entity_commands: entity_commands,
57
48
  queries: queries, entity_queries: entity_queries, read_models: read_models,
58
49
  populators: populators(runtime),
50
+ # Every `role "..."` any command in the boot declares — the
51
+ # "wrong hat" pool the caller draw's `mismatched` shape picks
52
+ # from (adversary.rb `other_role`).
53
+ roles: (creating + instance + entity_commands).filter_map { |e| e[:command].role }
54
+ .map(&:to_s).reject(&:empty?).uniq.sort,
59
55
  # Which aggregates this corpus can actually make one of — the ones
60
56
  # `satisfiable?` is entitled to wait for.
61
57
  creatable: creating.to_set { |entry| entry[:aggregate].hecks_name } }
62
58
  end
63
59
 
64
- # Which command, on which aggregate, appends to which entity list — so a
65
- # successful dispatch can predict the identity the element it just added
66
- # landed on. Entity#identified_by is filled by `Array(current).size + 1`
60
+ # EVERY ENTITY, AT EVERY DEPTH `Card` nested inside `Board`
61
+ # inside `Workspace` (qa/stress_domains/nested_pieces) walks in
62
+ # as `chain: [Board, Card]`, the exact hop list
63
+ # `EntityInterpreter#walk_entity_chain` resolves the dotted verb
64
+ # back into. Before this walk existed the catalog only ever read
65
+ # `aggregate.entities` one level down, so a two-hop entity
66
+ # command (BUG#11's whole class) could never be generated at all
67
+ # — the one shape the differential harness most needed to reach
68
+ # was structurally absent from every sequence it ever produced.
69
+ # `entity:` stays the LAST hop (what every existing reader means
70
+ # by "the entity"); `chain:` is the whole path.
71
+ #
72
+ # Entity QUERIES stay one hop deep, exactly as before — a nested
73
+ # entity's query has no established wire spelling this generator
74
+ # can vouch for, and nothing in the corpus declares one.
75
+ def catalog_entities(domain_name, aggregate, entity_commands, entity_queries)
76
+ each_entity_chain(aggregate) do |chain|
77
+ entity = chain.last
78
+ path = chain.map(&:hecks_name).join(".")
79
+ entity.commands.each do |command|
80
+ entity_commands << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{path}.#{command.hecks_name}",
81
+ command: command, aggregate: aggregate, entity: entity, chain: chain }
82
+ end
83
+ next unless chain.size == 1
84
+
85
+ entity.queries.each do |query|
86
+ entity_queries << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{path}.#{query.name}",
87
+ query: query, aggregate: aggregate, entity: entity }
88
+ end
89
+ end
90
+ end
91
+
92
+ # Depth-first, parents before children, in declaration order — so
93
+ # the depth-1 entries land in `entity_commands` in EXACTLY the
94
+ # order they always did (a pinned seed's picker pool is the same
95
+ # pool it was), and a nested entity's own entries follow its
96
+ # parent's.
97
+ def each_entity_chain(owner, chain = [], &block)
98
+ owner.entities.each do |entity|
99
+ path = chain + [entity]
100
+ yield path
101
+ each_entity_chain(entity, path, &block)
102
+ end
103
+ end
104
+
105
+ # Which command, on which owner, appends to which entity list — so a
106
+ # successful dispatch can predict the identity the element it just
107
+ # added landed on. Entity#identified_by is filled by `Array(current).size + 1`
67
108
  # (CommandInterpreter#entity_element) when the append's own field
68
109
  # mapping doesn't already assign it — the common case, predicted here.
69
110
  # A domain whose append explicitly assigns identity through a mapped
70
111
  # argument is covered too, without guessing: whatever value THIS
71
112
  # generator supplied for that argument at dispatch time IS the
72
113
  # identity, and gets recorded directly (see `record_outcome`).
114
+ #
115
+ # `owner_chain:` — `[]` for an aggregate-level append (`Board.AddList`,
116
+ # `Folder.AddSlip`), the entity path for an ENTITY-level one
117
+ # (`Board.AddCard` appending into `Board.cards`, owner_chain
118
+ # `[Board]`) — so `record_outcome` can key the appended element
119
+ # under the exact parent-plus-hops it landed beneath.
120
+ # `identity_arguments:` — EVERY identity head the mapping sources
121
+ # from a command argument (a composite entity identity has several),
122
+ # what the adversarial duplicate-identity mutation replays;
123
+ # `identity_argument:` stays the single-head reading the existing
124
+ # auto-mint prediction already keys on.
73
125
  def populators(runtime)
74
126
  runtime.registry.bluebooks.each_value.flat_map do |bluebook|
75
127
  bluebook.aggregates.flat_map do |aggregate|
76
- aggregate.commands.filter_map do |command|
77
- append = command.mutations.find { |mutation| mutation.op == :append }
78
- next unless append
79
-
80
- list_attribute = aggregate.attribute(append.target)
81
- next unless list_attribute&.list?
82
-
83
- entity = aggregate.entities.find { |candidate| candidate.hecks_name == list_attribute.type.to_s }
84
- next unless entity
128
+ owners = [[aggregate, []]]
129
+ each_entity_chain(aggregate) { |chain| owners << [chain.last, chain] }
85
130
 
86
- identity_field = entity.identified_by
87
- mapped = identity_field && append.source[identity_field]
88
- { command: command, aggregate: aggregate, entity: entity,
89
- identity_field: identity_field, identity_argument: mapped.is_a?(Symbol) ? mapped : nil }
131
+ owners.flat_map do |owner, chain|
132
+ owner.commands.filter_map { |command| populator_for(aggregate, owner, chain, command) }
90
133
  end
91
134
  end
92
135
  end
93
136
  end
137
+
138
+ def populator_for(aggregate, owner, chain, command)
139
+ append = command.mutations.find { |mutation| mutation.op == :append }
140
+ return unless append
141
+
142
+ list_attribute = owner.attribute(append.target)
143
+ return unless list_attribute&.list?
144
+
145
+ entity = owner.entities.find { |candidate| candidate.hecks_name == list_attribute.type.to_s }
146
+ return unless entity
147
+
148
+ identity_field = entity.identified_by
149
+ mapped = identity_field && append.source[identity_field]
150
+ identity_arguments = entity.identity_heads.filter_map do |head|
151
+ source = append.source[head]
152
+ source if source.is_a?(Symbol)
153
+ end
154
+ { command: command, aggregate: aggregate, owner: owner, owner_chain: chain, entity: entity,
155
+ identity_field: identity_field, identity_argument: mapped.is_a?(Symbol) ? mapped : nil,
156
+ identity_arguments: identity_arguments }
157
+ end
94
158
  end
95
159
  end
96
160
  end