hecks 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/memory.rb +1 -1
  4. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  5. data/lib/hecks/adapters/driven/prism.rb +1 -1
  6. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  7. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  8. data/lib/hecks/behaviors/expectations.rb +2 -2
  9. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  10. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  11. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  12. data/lib/hecks/bluebook/assembly.rb +1 -1
  13. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  14. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  15. data/lib/hecks/bluebook/capabilities.rb +2 -2
  16. data/lib/hecks/bluebook/chapter.rb +1 -1
  17. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  18. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  19. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  21. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  22. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  23. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
  26. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  27. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  28. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  29. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  30. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  31. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  32. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  33. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  34. data/lib/hecks/bluebook/model_check.rb +12 -13
  35. data/lib/hecks/cache_dir.rb +103 -0
  36. data/lib/hecks/cli/document.rb +104 -0
  37. data/lib/hecks/cli/ir.rb +47 -0
  38. data/lib/hecks/cli/mcp.rb +32 -0
  39. data/lib/hecks/cli/mcp_door.rb +461 -0
  40. data/lib/hecks/cli/model_check.rb +270 -0
  41. data/lib/hecks/cli/project_cli.rb +123 -0
  42. data/lib/hecks/cli/project_diagrams.rb +43 -0
  43. data/lib/hecks/cli/run.rb +219 -0
  44. data/lib/hecks/cli/smoke_test.rb +59 -0
  45. data/lib/hecks/cli/stores.rb +54 -0
  46. data/lib/hecks/cli.rb +166 -0
  47. data/lib/hecks/facade/handle.rb +2 -2
  48. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  50. data/lib/hecks/mcp_door_scope.rb +176 -0
  51. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  52. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
  53. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  54. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  55. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  56. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  57. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  58. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
  59. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  60. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  61. data/lib/hecks/projections/deploy/lambda.rb +3 -3
  62. data/lib/hecks/projections/reference.rb +7 -1
  63. data/lib/hecks/projector/exporter.rb +4 -4
  64. data/lib/hecks/projector/target.rb +1 -1
  65. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  66. data/lib/hecks/release/runner/commands.rb +68 -0
  67. data/lib/hecks/release/runner/console.rb +52 -0
  68. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  69. data/lib/hecks/release/runner/git.rb +48 -0
  70. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  71. data/lib/hecks/release/runner/preflight.rb +119 -0
  72. data/lib/hecks/release/runner/published.rb +55 -0
  73. data/lib/hecks/release/runner/tagger.rb +87 -0
  74. data/lib/hecks/release/runner.rb +244 -0
  75. data/lib/hecks/router/namespace_installer.rb +1 -1
  76. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  77. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  78. data/lib/hecks/runtime/entity_element.rb +1 -1
  79. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  80. data/lib/hecks/runtime/invocation.rb +1 -1
  81. data/lib/hecks/runtime/loader.rb +1 -1
  82. data/lib/hecks/runtime/outbox.rb +1 -1
  83. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  84. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  85. data/lib/hecks/runtime/reference_hop.rb +4 -4
  86. data/lib/hecks/runtime/registry/verification.rb +11 -11
  87. data/lib/hecks/runtime/registry.rb +8 -4
  88. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  89. data/lib/hecks/runtime/value/coercion.rb +3 -3
  90. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  91. data/lib/hecks/storehouse.rb +18 -12
  92. data/lib/hecks/version.rb +1 -1
  93. data/lib/hecks/vocabulary.rb +5 -0
  94. data/lib/hecks.rb +6 -1
  95. metadata +28 -58
  96. data/lib/hecks/bench/cli.rb +0 -74
  97. data/lib/hecks/bench/environment.rb +0 -100
  98. data/lib/hecks/bench/postgres_probe.rb +0 -41
  99. data/lib/hecks/bench/report.rb +0 -116
  100. data/lib/hecks/bench/ruby_runner.rb +0 -151
  101. data/lib/hecks/bench/run.rb +0 -55
  102. data/lib/hecks/bench/rust_runner.rb +0 -165
  103. data/lib/hecks/bench/stats.rb +0 -75
  104. data/lib/hecks/bench/suite.rb +0 -172
  105. data/lib/hecks/bench/workload.rb +0 -137
  106. data/lib/hecks/bench.rb +0 -28
  107. data/lib/hecks/codemod.rb +0 -443
  108. data/lib/hecks/corpus.rb +0 -479
  109. data/lib/hecks/doc/reference.rb +0 -585
  110. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  111. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  112. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  113. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  114. data/lib/hecks/fuzzing/differential.rb +0 -192
  115. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  116. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  117. data/lib/hecks/fuzzing/form_census.rb +0 -285
  118. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  119. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  120. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  121. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  122. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  123. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  124. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  125. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  126. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  127. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  128. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  129. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  130. data/lib/hecks/fuzzing/properties.rb +0 -267
  131. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  132. data/lib/hecks/fuzzing/replay.rb +0 -930
  133. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  134. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  135. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  136. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  137. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  138. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  139. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  140. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  141. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  142. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  143. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  144. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  145. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  146. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  147. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  148. data/lib/hecks/fuzzing.rb +0 -23
  149. data/lib/hecks/grammar/evolve.rb +0 -524
  150. data/lib/hecks/query_ir.rb +0 -482
@@ -1,532 +0,0 @@
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
- # ## Why these shapes
12
- #
13
- # Ten bugs in one QA session (BUG#7–#16, `QualityControl`'s own
14
- # ledger) clustered in a handful of mechanisms — and several were
15
- # only ever reachable because one domain happened to declare the
16
- # argument that tripped them: `Roster::Roster.Mark`'s own `to:`
17
- # collided with the dispatcher's routing `to:` (BUG#7), a present-
18
- # but-null `to:` was misread as a routing envelope (BUG#16), a blank
19
- # creating identity minted a phantom aggregate in Rust (BUG#15), a
20
- # single-field closed-set argument offered as bare `null` refused
21
- # with different kinds on the two engines (BUG#14). No other domain
22
- # in the rotation could ever have found any of those, because
23
- # nothing generated ever produced the shape. This module produces
24
- # them — for a configurable fraction of command steps, chosen and
25
- # parameterised from the same seeded RNG the rest of the generator
26
- # already draws from, so `generate(domain, seed:, steps:,
27
- # adversarial:)` stays exactly as reproducible per seed as it was.
28
- #
29
- # ## Opt-in, by construction
30
- #
31
- # `adversarial: 0.0` (the default) returns before drawing a single
32
- # random number, so every pinned seed in spec/fuzzing,
33
- # spec/rust_conformance_fuzz_spec.rb, bin/fuzz and bin/generate
34
- # produces byte-for-byte what it produced before this module
35
- # existed. `bin/qa_sweep` turns it on (reading
36
- # `QualityControlDials::ADVERSARIAL_FRACTION`) — that script is the
37
- # one place a mutated step's own divergence is a finding rather than
38
- # a red CI gate.
39
- #
40
- # ## When it applies
41
- #
42
- # One mutation per step, applied in `StepBuilder#build_command_step`
43
- # after the arguments and identity are built and before the step's
44
- # own inline dispatch — so the generator's own `known_ids` tracking
45
- # sees what really happened, and so the returned step's `args` are
46
- # the mutated bytes both `Fuzzing::Replay` (Ruby) and the compiled
47
- # conformance binary (Rust) later receive. The step carries an
48
- # `"adversarial"` key naming what was done (`mutation`, the bug
49
- # class it exercises, and the exact sub-shape), which `bin/qa_sweep`
50
- # prints on a found something report so the agent logging the Bug
51
- # can name the mechanism, not guess it. Both replay paths ignore
52
- # the key: `Replay.call` reads only verb/query/dry_run/args/role,
53
- # and `kernel/cli.rs` reads step keys by name.
54
- module Adversary
55
- KINDS = %i[
56
- routing_key
57
- blank_identity
58
- null_value_object
59
- duplicate_entity_identity
60
- omit_mapped_argument
61
- refusal_precedence
62
- ].freeze
63
-
64
- # BUG#7/#16/#8 — `to`/`with` are `Dispatcher#dispatch`'s own
65
- # routing keywords (Ruby's kwarg binding steals them from a flat
66
- # args hash; Rust's `CommandInvocation::from_json` reads the same
67
- # two names off the same object), `id` is the untyped identity
68
- # fallback `ArgumentGate#refuse_unknown_arguments` exempts. Three
69
- # shapes each: bare null (BUG#16), a scalar (BUG#7's out-of-range
70
- # Integer among them), and the routing-shaped object.
71
- ROUTING_KEYS = %w[to with id].freeze
72
- ROUTING_SHAPES = %w[null scalar route].freeze
73
-
74
- # BUG#15 — a creating command's own identity part, blank three ways.
75
- BLANK_SHAPES = %w[empty whitespace null].freeze
76
-
77
- # BUG#14 — a single-field value object as bare `null` and as `{}`.
78
- VALUE_OBJECT_SHAPES = %w[null empty_object].freeze
79
-
80
- # BUG#7/#8/#14's class — an unknown key, a type-mismatched declared
81
- # key and an absent required key in the same step, so both engines
82
- # have to pick which refusal wins; and each pair, so the ordering
83
- # of any two is observable on a command too small for all three.
84
- #
85
- # The second row reaches further down `DISPATCH_ORDER`
86
- # (lib/hecks/vocabulary.rb): `nonexistent` addresses an id nothing
87
- # holds (a `hydrate`-stage NotFound), `lifecycle` aims a
88
- # transition-guarded command at a real record (an
89
- # `admissible_transition`-stage refusal, if the record's state
90
- # refuses it), `role` binds a caller whose role the command does
91
- # not name (the `refuse_role_mismatch` stage no generated step
92
- # had ever reached). Paired with an argument-stage fault each,
93
- # so the ordering of an early stage against a late one is
94
- # observable — BUG#13 (ledger_ordering's own NOTES.md) was
95
- # exactly an argument-invariant-vs-entity-existence ordering
96
- # split, and nothing generated had ever asked the question on
97
- # purpose.
98
- PRECEDENCE_SHAPES = %w[
99
- unknown+mismatch+absent unknown+absent unknown+mismatch mismatch+absent
100
- nonexistent+mismatch nonexistent+unknown lifecycle+mismatch role+absent role+nonexistent
101
- ].freeze
102
-
103
- # Item 2 of the detection plan (angle-5) — a drawn caller on a
104
- # role-gated command. `refuse_role_mismatch` is a `DISPATCH_ORDER`
105
- # step both engines implement (`command_rules/authorization.rb`,
106
- # `rust/src/kernel/repository.rs check_role`) and both replay doors
107
- # already read (`Fuzzing::Replay` binds `Hecks.as_caller` from a
108
- # step's `role:`/`actor_id:`; `kernel/cli.rs` reads the same two
109
- # keys) — yet no generated step ever carried either key, so the
110
- # check was dormant on every sweep ever run. Five shapes:
111
- # matching the command's own role — the string fallback
112
- # authorizes it on both engines
113
- # mismatched another declared role (or none the domain
114
- # knows) — Unauthorized on both
115
- # absent_on_gated no caller at all on a gated command — the
116
- # unchecked default, recorded so the step reads
117
- # as a deliberate control, not an omission
118
- # actor_known the role plus an actor this same sequence
119
- # already granted it to (the authorization
120
- # provider's declared `grant:` verb succeeded
121
- # earlier — `catalog[:grant_verbs]`) —
122
- # the real `holds_role?` lookup, both sides
123
- # actor_unknown the role plus an actor nothing granted —
124
- # `holds_role?` must refuse on both
125
- # a separate layer from `KINDS`, with its own probability
126
- # (`role_draw:` — `QualityControlDials::ROLE_DRAW_PROBABILITY`):
127
- # a caller composes with any argument mutation above rather than
128
- # competing with it for the one-mutation-per-step slot, and draws
129
- # nothing from the RNG when off, so every pinned seed is
130
- # byte-identical to before it existed.
131
- CALLER_SHAPES = %w[matching mismatched absent_on_gated actor_known actor_unknown].freeze
132
- UNKNOWN_ROLE = "Nobody the domain names".freeze
133
-
134
- # BUG#11 — an entity command two or more hops deep. Not a mutation
135
- # of arguments but a preference (picker.rb weights these up when
136
- # adversarial) plus an addressing coin: flat one-head-per-hop args
137
- # (what every other generated entity step uses) or the routed
138
- # `to: { aggregate:, entities: [...] }` envelope BUG#11's own fix
139
- # was scoped to. The step's metadata reports the depth reached
140
- # so a depth-3 domain is visibly exercised there.
141
- DEEP_ENTITY_DEPTH = 2
142
- DEEP_ENTITY_WEIGHT = 4
143
-
144
- # BUG#13's mutation is only ever applicable on a step whose
145
- # parent already holds an element this same sequence appended —
146
- # a rare moment (an append has to have succeeded first, and most
147
- # mutated appends are refused), so when it is on offer it is
148
- # weighted up the same way the picker weights an unexercised
149
- # verb: the opportunity is what is scarce, not the kind.
150
- DUPLICATE_IDENTITY_WEIGHT = 3
151
-
152
- private
153
-
154
- def adversarial? = @adversarial.positive?
155
-
156
- # `[]` — and no RNG draw — when adversarial mode is off. Otherwise
157
- # the deep-entity addressing note (every deep step, when
158
- # adversarial), then with probability `@adversarial` exactly one
159
- # mutation among those applicable to this step's own command.
160
- def adversarial_mutations!(args, entry, catalog)
161
- return [] unless adversarial?
162
-
163
- mutations = []
164
- mutations << deep_entity_addressing!(args, entry) if (entry[:chain] || []).size >= DEEP_ENTITY_DEPTH
165
- return mutations if @random.rand >= @adversarial
166
-
167
- applicable = KINDS.select { |kind| send(:"#{kind}_applicable?", args, entry, catalog) }
168
- return mutations if applicable.empty?
169
-
170
- weighted = applicable.flat_map { |kind| [kind] * (kind == :duplicate_entity_identity ? DUPLICATE_IDENTITY_WEIGHT : 1) }
171
- mutations << send(:"apply_#{weighted.sample(random: @random)}!", args, entry, catalog)
172
- end
173
-
174
- # ── BUG#11: depth ≥ 2 entity command, flat or routed addressing ──
175
-
176
- def deep_entity_addressing!(args, entry)
177
- depth = entry[:chain].size
178
- routed = @random.rand(2).zero?
179
- note = { "mutation" => "deep_entity", "bug" => "BUG#11", "depth" => depth,
180
- "addressing" => routed ? "routed" : "flat" }
181
- return note unless routed
182
-
183
- heads = [entry[:aggregate], *entry[:chain]].map { |construct| (construct.identified_by || :id).to_s }
184
- scalars = heads.map { |head| ValueGenerator.scalar_of(args[head]) }
185
- # The heads leave the flat args — this is the clean routed
186
- # caller BUG#11's own spec pins (`to: {...}, note: {...}`) —
187
- # unless the command itself declares an attribute of that name
188
- # (chess's `Piece.Move` declares `id` as a fact too), which
189
- # stays because dropping it would be a different mutation.
190
- heads.each { |head| args.delete(head) unless entry[:command].attribute(head) }
191
- args["to"] = { "aggregate" => scalars.first, "entities" => scalars.drop(1) }
192
- note
193
- end
194
-
195
- # ── BUG#7/#16/#8: an undeclared routing/identity key on flat args ──
196
-
197
- # Not on a step already carrying a routed `to:` from
198
- # `deep_entity_addressing!` — overwriting that envelope would
199
- # leave the deep-entity note claiming an addressing the args no
200
- # longer have.
201
- def routing_key_applicable?(args, _entry, _catalog) = !args.key?("to")
202
-
203
- def apply_routing_key!(args, entry, _catalog)
204
- key = ROUTING_KEYS.sample(random: @random)
205
- shape = ROUTING_SHAPES.sample(random: @random)
206
- args[key] =
207
- case shape
208
- when "null" then nil
209
- when "scalar" then routing_scalar(args, entry)
210
- else routing_object(args, entry)
211
- end
212
- { "mutation" => "routing_key", "bug" => "BUG#7/#16/#8", "key" => key, "shape" => shape,
213
- "declared" => !entry[:command].attribute(key).nil? }
214
- end
215
-
216
- # A real-looking id (this step's own parent, so a routed envelope
217
- # can actually resolve), BUG#7's own out-of-range Integer, or a
218
- # minted id nothing holds.
219
- def routing_scalar(args, entry)
220
- case @random.rand(3)
221
- when 0 then parent_scalar_of(args, entry)
222
- when 1 then ValueGenerator::INTEGER_EDGE_CASES.sample(random: @random)
223
- else ValueGenerator.random_id(@random)
224
- end
225
- end
226
-
227
- def routing_object(args, entry)
228
- entities = (entry[:chain] || []).map { |piece| ValueGenerator.scalar_of(args[(piece.identified_by || :id).to_s]) }
229
- # Half the time one identity too many — a depth the verb does
230
- # not have, which `Routing.envelope`'s `entity_depth` check and
231
- # Rust's own envelope parser must both refuse the same way.
232
- entities << ValueGenerator.random_id(@random) if @random.rand(2).zero?
233
- { "aggregate" => parent_scalar_of(args, entry), "entities" => entities }
234
- end
235
-
236
- def parent_scalar_of(args, entry)
237
- key = (entry[:aggregate].identified_by || :id).to_s
238
- args.key?(key) ? ValueGenerator.scalar_of(args[key]) : identity_scalar_of(entry[:aggregate], args)
239
- end
240
-
241
- # ── BUG#15: a blank identity part on a creating step ──────────────
242
-
243
- def blank_identity_applicable?(args, entry, catalog) = blank_identity_targets(args, entry, catalog).any?
244
-
245
- # The identity heads this step supplies itself: a creating
246
- # aggregate command's own (every part of a composite), and an
247
- # append's caller-supplied entity identity arguments (an entity is
248
- # "created" by its append, and BUG#15's blank-identity question
249
- # applies there too).
250
- def blank_identity_targets(args, entry, catalog)
251
- targets = []
252
- if entry[:entity].nil? && entry[:command].creates?
253
- aggregate = entry[:aggregate]
254
- heads = composite_identity?(aggregate) ? aggregate.identity_heads : [aggregate.identified_by || :id]
255
- targets.concat(heads.map(&:to_s))
256
- end
257
- populator = populator_for_entry(catalog, entry)
258
- targets.concat(populator[:identity_arguments].map(&:to_s)) if populator
259
- targets.uniq.select { |head| args.key?(head) }
260
- end
261
-
262
- def apply_blank_identity!(args, entry, catalog)
263
- head = blank_identity_targets(args, entry, catalog).sample(random: @random)
264
- shape = BLANK_SHAPES.sample(random: @random)
265
- blank = shape == "empty" ? "" : " "
266
- args[head] =
267
- if shape == "null" then nil
268
- elsif args[head].is_a?(Hash) then args[head].transform_values { blank }
269
- else blank
270
- end
271
- { "mutation" => "blank_identity", "bug" => "BUG#15", "argument" => head, "shape" => shape }
272
- end
273
-
274
- # ── BUG#14: a single-field value object as null / {} ─────────────
275
-
276
- def null_value_object_applicable?(args, entry, _catalog) = value_object_targets(args, entry).any?
277
-
278
- def value_object_targets(args, entry)
279
- aggregate = entry[:aggregate]
280
- entry[:command].attributes.reject { |attribute| attribute.list? || attribute.reference? }
281
- .select { |attribute| args.key?(attribute.name.to_s) }
282
- .filter_map do |attribute|
283
- value_object = Runtime::Value.value_object_for(aggregate, attribute.type.to_s)
284
- [attribute, value_object] if value_object&.sole_attribute
285
- end
286
- end
287
-
288
- def apply_null_value_object!(args, entry, _catalog)
289
- targets = value_object_targets(args, entry)
290
- closed = targets.select { |_, value_object| value_object.closed_set? }
291
- attribute, value_object = (closed.empty? ? targets : closed).sample(random: @random)
292
- shape = VALUE_OBJECT_SHAPES.sample(random: @random)
293
- args[attribute.name.to_s] = shape == "null" ? nil : {}
294
- { "mutation" => "null_value_object", "bug" => "BUG#14", "argument" => attribute.name.to_s,
295
- "value_object" => value_object.hecks_name, "closed_set" => value_object.closed_set? == true,
296
- "shape" => shape }
297
- end
298
-
299
- # ── BUG#13: an entity identity this sequence already appended ────
300
-
301
- def duplicate_entity_identity_applicable?(args, entry, catalog) = duplicate_identity_pool(args, entry, catalog).any?
302
-
303
- def duplicate_identity_pool(args, entry, catalog)
304
- populator = populator_for_entry(catalog, entry)
305
- return [] unless populator && populator[:identity_arguments].any?
306
-
307
- @appended_identities[append_pool_key(populator, args)]
308
- end
309
-
310
- def apply_duplicate_entity_identity!(args, entry, catalog)
311
- populator = populator_for_entry(catalog, entry)
312
- tuple = duplicate_identity_pool(args, entry, catalog).sample(random: @random)
313
- args.merge!(tuple)
314
- { "mutation" => "duplicate_entity_identity", "bug" => "BUG#13", "entity" => populator[:entity].hecks_name,
315
- "composite" => populator[:identity_arguments].size > 1, "identity" => tuple }
316
- end
317
-
318
- # ── BUG#12: a mapped/declared attribute left out of the payload ──
319
-
320
- def omit_mapped_argument_applicable?(args, entry, _catalog) = mapped_argument_targets(args, entry).any?
321
-
322
- # For an append: the arguments its `append:` mapping sources (the
323
- # element's own declared fields). For a plain creating command:
324
- # every declared non-identity attribute. Never an identity head —
325
- # that is `blank_identity`'s question, not this one.
326
- def mapped_argument_targets(args, entry)
327
- command = entry[:command]
328
- heads = identity_heads_of(entry)
329
- mapped = command.mutations.select { |mutation| mutation.op == :append }
330
- .flat_map { |mutation| mutation.source.values.grep(Symbol).map(&:to_s) }
331
- if mapped.empty? && !entry.key?(:entity) && command.creates?
332
- mapped = command.attributes.map { |attribute| attribute.name.to_s }
333
- end
334
- command.attributes.select do |attribute|
335
- name = attribute.name.to_s
336
- mapped.include?(name) && args.key?(name) && !heads.include?(name)
337
- end
338
- end
339
-
340
- def apply_omit_mapped_argument!(args, entry, _catalog)
341
- attribute = mapped_argument_targets(args, entry).sample(random: @random)
342
- args.delete(attribute.name.to_s)
343
- { "mutation" => "omit_mapped_argument", "bug" => "BUG#12", "argument" => attribute.name.to_s,
344
- "optional" => attribute.optional? == true }
345
- end
346
-
347
- # ── BUG#7/#8/#14: unknown + mismatched + absent, in one step ─────
348
-
349
- def refusal_precedence_applicable?(args, entry, _catalog)
350
- precedence_shapes_for(args, entry).any?
351
- end
352
-
353
- # Every shape whose every part this step can carry. `nonexistent`
354
- # and `lifecycle` need a command that acts on a record (a creating
355
- # step has no addressed id to point elsewhere, and no state to be
356
- # in) and flat addressing (`deep_entity_addressing!`'s routed `to:`
357
- # envelope owns the ids then); `lifecycle` additionally needs a
358
- # command some guard actually watches; `role` needs a declared
359
- # `role` to mismatch against.
360
- def precedence_shapes_for(args, entry)
361
- can = {
362
- "mismatch" => corruptible_attributes(args, entry).any?,
363
- "absent" => droppable_required_attributes(args, entry).any?,
364
- "unknown" => true,
365
- "nonexistent" => acts_on_record?(args, entry),
366
- "lifecycle" => acts_on_record?(args, entry) && transition_guarded?(entry),
367
- "role" => !entry[:command].role.to_s.empty?
368
- }
369
- PRECEDENCE_SHAPES.select { |shape| shape.split("+").all? { |part| can.fetch(part) } }
370
- end
371
-
372
- def acts_on_record?(args, entry) = !entry[:command].creates? && !args.key?("to")
373
-
374
- def transition_guarded?(entry)
375
- command = entry[:command]
376
- owner = entry.key?(:entity) ? entry[:entity] : entry[:aggregate]
377
- return true if command.from
378
- return false unless owner.respond_to?(:lifecycle)
379
-
380
- owner.lifecycle&.transitions_for(command.hecks_name)&.any? || false
381
- end
382
-
383
- def apply_refusal_precedence!(args, entry, catalog)
384
- corruptible = corruptible_attributes(args, entry)
385
- droppable = droppable_required_attributes(args, entry)
386
- wanted = precedence_shapes_for(args, entry).sample(random: @random).split("+")
387
- detail = { "mutation" => "refusal_precedence", "bug" => "BUG#7/#8/#14" }
388
- applied = []
389
-
390
- if wanted.include?("absent")
391
- dropped = droppable.sample(random: @random)
392
- args.delete(dropped.name.to_s)
393
- detail["absent"] = dropped.name.to_s
394
- applied << "absent"
395
- corruptible -= [dropped]
396
- end
397
- if wanted.include?("mismatch") && corruptible.any?
398
- attribute = corruptible.sample(random: @random)
399
- args[attribute.name.to_s] = InvalidValueGenerator.corrupt(attribute, entry[:aggregate], random: @random)
400
- detail["mismatched"] = attribute.name.to_s
401
- applied << "mismatch"
402
- end
403
- if wanted.include?("unknown")
404
- name, value = InvalidValueGenerator.undeclared_argument(random: @random)
405
- args[name] = value
406
- detail["unknown"] = name
407
- applied << "unknown"
408
- end
409
- apply_late_stage_parts!(wanted, args, entry, detail, applied, catalog)
410
- # Reported as what was actually done — a command with a single
411
- # attribute cannot carry both a dropped and a corrupted one.
412
- detail.merge("shape" => applied.sort.join("+"))
413
- end
414
-
415
- # The three parts that reach past the argument gate — see
416
- # `PRECEDENCE_SHAPES`' own second row. `nonexistent` re-addresses
417
- # the step's last hop (the entity element for an entity command,
418
- # the aggregate itself otherwise) to an id nothing holds;
419
- # `lifecycle` mutates nothing (the record's own state is what
420
- # refuses, or doesn't) and is recorded so the pairing is visible;
421
- # `role` parks a mismatched caller for `StepBuilder` to bind
422
- # around this one dispatch.
423
- def apply_late_stage_parts!(wanted, args, entry, detail, applied, catalog)
424
- if wanted.include?("nonexistent")
425
- piece = (entry[:chain] || []).last || entry[:aggregate]
426
- head = (piece.identified_by || :id).to_s
427
- args[head] = identity_shaped(piece, piece.identified_by, ValueGenerator.random_id(@random), entry[:aggregate])
428
- detail["nonexistent"] = head
429
- applied << "nonexistent"
430
- end
431
- if wanted.include?("lifecycle")
432
- detail["lifecycle"] = entry[:command].from || "transition-guarded"
433
- applied << "lifecycle"
434
- end
435
- return unless wanted.include?("role")
436
-
437
- @precedence_caller = { "role" => other_role(entry[:command].role.to_s, catalog) }
438
- detail["role"] = @precedence_caller["role"]
439
- applied << "role"
440
- end
441
-
442
- # ── Angle-5: a drawn caller on a role-gated command ──────────────
443
-
444
- def role_draw? = @role_draw.positive?
445
-
446
- # `[caller, note]` — `caller` is the `{"role" => …, "actor_id" =>
447
- # …}` pair (or nil for the unchecked control) `StepBuilder` binds
448
- # around the step's one dispatch and writes onto the step itself;
449
- # `note` rides in the step's `"adversarial"` metadata. A
450
- # `refusal_precedence` mutation that already parked a caller for
451
- # this step wins outright (its whole point is that pairing); an
452
- # ungated command draws nothing (there is no role to match or
453
- # mismatch), and with the draw off nothing is drawn at all.
454
- def caller_draw!(entry, catalog)
455
- if @precedence_caller
456
- caller = @precedence_caller
457
- @precedence_caller = nil
458
- return [caller, nil]
459
- end
460
-
461
- role = entry[:command].role.to_s
462
- return [nil, nil] if !role_draw? || role.empty? || @random.rand >= @role_draw
463
-
464
- shapes = CALLER_SHAPES.dup
465
- shapes.delete("actor_known") if @granted[role].empty?
466
- shape = shapes.sample(random: @random)
467
- caller = caller_for_shape(shape, role, catalog)
468
- note = { "mutation" => "caller_role", "angle" => "ANGLE-5", "shape" => shape, "gated_role" => role }
469
- [caller, caller ? note.merge(caller) : note]
470
- end
471
-
472
- def caller_for_shape(shape, role, catalog)
473
- case shape
474
- when "matching" then { "role" => role }
475
- when "mismatched" then { "role" => other_role(role, catalog) }
476
- when "actor_known" then { "role" => role, "actor_id" => @granted[role].sample(random: @random) }
477
- when "actor_unknown" then { "role" => role, "actor_id" => ValueGenerator.random_id(@random) }
478
- end
479
- end
480
-
481
- # Another role the domain itself declares, when it has one — a
482
- # real "wrong hat", the more interesting mismatch — else a role no
483
- # bluebook names at all.
484
- def other_role(role, catalog)
485
- others = catalog[:roles] - [role]
486
- others.empty? ? UNKNOWN_ROLE : others.sample(random: @random)
487
- end
488
-
489
- def corruptible_attributes(args, entry)
490
- entry[:command].attributes.reject(&:list?).select { |attribute| args.key?(attribute.name.to_s) }
491
- end
492
-
493
- def droppable_required_attributes(args, entry)
494
- heads = identity_heads_of(entry)
495
- entry[:command].attributes.reject(&:optional?).select do |attribute|
496
- args.key?(attribute.name.to_s) && !heads.include?(attribute.name.to_s)
497
- end
498
- end
499
-
500
- # A grant aimed at a role some command actually declares. Left to
501
- # `ValueGenerator`, `Assign`'s `role_name` is random text
502
- # ("hotel"), which no command is gated on — so a granted actor
503
- # could never satisfy `holds_role?` for anything, and the
504
- # `actor_known` shape above would be unreachable by construction.
505
- # With the draw on, every generated grant names one of the
506
- # domain's own declared roles instead; with it off, nothing here
507
- # runs (no RNG draw, no change to the args).
508
- def steer_grant!(args, entry, catalog)
509
- return unless role_draw? && catalog[:grant_verbs].include?(entry[:verb]) && catalog[:roles].any?
510
- return unless args.key?("role_name")
511
-
512
- args["role_name"] = { "value" => catalog[:roles].sample(random: @random) }
513
- end
514
-
515
- # ── shared ───────────────────────────────────────────────────────
516
-
517
- def populator_for_entry(catalog, entry)
518
- owner = entry.key?(:entity) ? entry[:entity] : entry[:aggregate]
519
- catalog[:populators].find { |p| p[:command].equal?(entry[:command]) && p[:owner].equal?(owner) }
520
- end
521
-
522
- # Every identity head this step addresses by: the aggregate's own
523
- # (plus the untyped `id` fallback) and one per entity hop.
524
- def identity_heads_of(entry)
525
- heads = entry[:aggregate].identity_heads.map(&:to_s) + ["id"]
526
- (entry[:chain] || []).each { |piece| heads.concat(piece.identity_heads.map(&:to_s)) }
527
- heads.uniq
528
- end
529
- end
530
- end
531
- end
532
- end