hecks 0.3.0 → 1.0.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -1,5 +1,6 @@
1
1
  require "fileutils"
2
2
  require "tmpdir"
3
+ require "securerandom"
3
4
 
4
5
  module Hecks
5
6
  module Fuzzing
@@ -23,15 +24,58 @@ module Hecks
23
24
  # domain's own rules and shape are untouched ; only WHICH adapter this
24
25
  # one ephemeral copy answers through changes. What the domain is
25
26
  # bound to for real deployment is never touched — only this tmp copy.
27
+ #
28
+ # `adapter:` (PRD 02) — Memory is the default and the only mode every
29
+ # existing caller still gets with no change. `:sqlite` rebinds to the
30
+ # REAL SQLite adapter instead of the in-memory one, for exactly the
31
+ # same reason PRD 02 exists: 15 declared properties (properties.rb)
32
+ # and every fuzz/replay run has only ever been checked against
33
+ # Memory's own hand-written repository, never against a real,
34
+ # persisted, SQL-backed one — and `spec/adapters/query_agreement_spec
35
+ # .rb` already found 4 shipped query bugs from exactly that
36
+ # comparison, on a fixed corpus far smaller than what the fuzzer
37
+ # generates. `:sqlite` rewrites to `"SqlitePersistence"` — the name
38
+ # `lib/hecks/adapters/driven/sqlite.adapter` actually registers under
39
+ # (`Sqlite` is the class; `SqlitePersistence` is a thin subclass
40
+ # that's the one real port binding names) — and needs no `.world`
41
+ # settings at all, the same reason Memory needs none:
42
+ # `Sqlite#resolve_path` already defaults an
43
+ # unbound `database` setting to `"data/<table>.db"` relative to
44
+ # `root:`, which `Hecks.boot(copy)` passes as this ephemeral copy's
45
+ # own directory — a fresh, empty `data/` per run, exactly like
46
+ # Memory's own zero-history guarantee, just backed by a real SQLite
47
+ # file instead of a Hash. Postgres/PostgresEra are deliberately NOT
48
+ # added here: a real Postgres run needs a live server and shared
49
+ # connection settings this in-process, no-adapter-config path has no
50
+ # place to source safely — that stays `io: true`-gated, direct-adapter
51
+ # coverage (`spec/adapters/driven/postgres_*_spec.rb`), not this.
26
52
  module IsolatedBoot
27
53
  module_function
28
54
 
29
- def call(domain_path)
55
+ # PRD 02 (docs/future-features.md) — `adapter:` picks WHICH real
56
+ # persistence this one ephemeral boot answers through, not just
57
+ # Memory. `:memory` is the original, zero-config behavior (every
58
+ # other caller in this codebase that doesn't pass `adapter:` gets
59
+ # exactly what it always got). `:sqlite` is nearly as cheap —
60
+ # `Adapters::Sqlite#resolve_path` already defaults to
61
+ # `data/<table>.db` under the copy's own root with no settings at
62
+ # all, the same "needs nothing declared" property Memory has, so a
63
+ # real on-disk B-tree gets exercised for free. `:postgres` is the
64
+ # expensive one — a real server, a real round trip per dispatch —
65
+ # so `bin/fuzz --adapter postgres` is meant to run with smaller
66
+ # seed/step counts than the Memory default, not as a like-for-like
67
+ # swap; see that flag's own comment.
68
+ def call(domain_path, adapter: :memory)
30
69
  Dir.mktmpdir("hecks-fuzz") do |tmp|
31
70
  copy = File.join(tmp, File.basename(domain_path))
32
71
  copy_dereferencing(domain_path, copy)
33
72
  FileUtils.rm_rf(File.join(copy, "data"))
34
- rebind_to_memory!(copy)
73
+ case adapter
74
+ when :memory then rebind_to_memory!(copy)
75
+ when :sqlite then rebind_to_sqlite!(copy)
76
+ when :postgres then rebind_to_postgres!(copy)
77
+ else raise ArgumentError, "unknown fuzz adapter #{adapter.inspect} — :memory, :sqlite, or :postgres"
78
+ end
35
79
  yield copy
36
80
  end
37
81
  end
@@ -47,8 +91,8 @@ module Hecks
47
91
  #
48
92
  # `FileUtils.cp` follows a symlink and copies its CONTENT, which is
49
93
  # what an isolated boot wants: the copy has to stand alone, since
50
- # rebind_to_memory! rewrites files in it and must not reach back
51
- # through a link into the real tree.
94
+ # rebind! rewrites files in it and must not reach back through a
95
+ # link into the real tree.
52
96
  def copy_dereferencing(source, destination)
53
97
  FileUtils.mkdir_p(destination)
54
98
  Dir.glob(File.join(source, "**", "*"), File::FNM_DOTMATCH).each do |path|
@@ -65,28 +109,178 @@ module Hecks
65
109
  end
66
110
 
67
111
  def rebind_to_memory!(copy)
68
- Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |path|
69
- # `persisted_by`/`projected_by` can be spelled two ways now —
70
- # aggregate-scoped (`Banking::Customer.persisted_by("Heki")`,
71
- # always parenthesised) and, since §0's domain-level default
72
- # binds, a bare call at the hecksagon's own root
73
- # (`persisted_by "Heki"`, no parens, no receiver). Both must
74
- # be caught here or a domain that only declares the bare form
75
- # keeps its real adapter under an "isolated" fuzz boot.
76
- lines = File.readlines(path).reject { |line| line.match?(/\bprojected_by\s*\(?\s*"/) }
77
- File.write(path, lines.join.gsub(/persisted_by\s*\(?\s*"[^"]+"\s*\)?/, 'persisted_by("Memory")'))
78
- end
112
+ rewrite_bindings!(copy, "Memory")
79
113
 
80
114
  # THE SETTINGS, NOT JUST THE BIND — `WorldBuilder#method_missing`
81
115
  # stores a settings block under BOTH "verb:adapter" and the bare
82
116
  # "verb" (world_builder.rb:32-33), so a bind rewritten to Memory
83
117
  # still falls back to whatever adapter's settings were declared
84
118
  # bare — Postgres's `database:`, which Memory does not take and
85
- # WiringError refuses on sight. Memory needs no settings at all
86
- # (a domain with no .world boots it as the default), so the
87
- # simplest correct fix is dropping .world from the copy entirely.
119
+ # WiringError refuses on sight. Both Memory and Sqlite need no
120
+ # settings at all (`Sqlite#resolve_path` defaults `database` to
121
+ # `data/<table>.db` under `root:` when unset the same
122
+ # zero-config default Memory gets from having no `.world` at
123
+ # all), so the simplest correct fix for either target is
124
+ # dropping `.world` from the copy entirely.
125
+ Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
126
+ end
127
+
128
+ # SAME DANCE AS MEMORY, ONE ADAPTER OVER — `Adapters::Sqlite#
129
+ # resolve_path` (adapters/driven/sqlite.rb) defaults to
130
+ # `data/<table>.db` under the boot's own root when no `database`
131
+ # setting is declared, which `data/` already being cleared makes a
132
+ # fresh on-disk store with zero configuration, the same "no .world
133
+ # needed" property Memory has. `data/` itself is left for Sqlite to
134
+ # recreate on first write, same as it always was for Memory/Heki —
135
+ # nothing here creates it up front.
136
+ def rebind_to_sqlite!(copy)
137
+ rewrite_bindings!(copy, "SqlitePersistence")
88
138
  Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
89
139
  end
140
+
141
+ # THE EXPENSIVE ONE — Postgres has no zero-config default the way
142
+ # Sqlite/Memory do (`Adapters::Postgres.connect_for` refuses outright
143
+ # with no `database` setting), so dropping `.world` the way the
144
+ # other two do would just move the WiringError from "wrong adapter"
145
+ # to "no adapter." Every domain name this copy declares gets a
146
+ # FRESH `.world` written for it instead — not a rewrite of whatever
147
+ # was there, a replacement, same reasoning `rewrite_bindings!`
148
+ # already applies to `.hecksagon`: this ephemeral boot owns every
149
+ # binding decision, nothing about a real deployment's own settings
150
+ # is relevant or safe to half-preserve here.
151
+ #
152
+ # ONE SHARED SCHEMA, DROPPED AND RECREATED BEFORE EVERY BOOT — not a
153
+ # fresh randomly-named one per call. `bin/fuzz` drives every
154
+ # ephemeral boot sequentially (one `IsolatedBoot.call` fully exits
155
+ # before the next begins — see that file's own single-threaded
156
+ # `while` loop), so nothing is ever concurrent here ; a fresh name
157
+ # every time would just leak schemas in `FUZZ_POSTGRES_DATABASE`
158
+ # forever with nothing to ever drop them. If a caller ever DOES
159
+ # start running fuzz adapters concurrently, this needs to move to a
160
+ # process-unique schema name (`SecureRandom.hex` is already
161
+ # `require`d here for exactly that day) — flagged, not solved,
162
+ # since nothing today calls this from more than one thread.
163
+ FUZZ_POSTGRES_DATABASE = "hecks_fuzz"
164
+ FUZZ_POSTGRES_SCHEMA = "hecks_fuzz"
165
+
166
+ def rebind_to_postgres!(copy)
167
+ require "pg"
168
+ rewrite_bindings!(copy, "Postgres")
169
+ ensure_fuzz_schema!
170
+
171
+ # ONE `.world` PER DIRECTORY A `.hecksagon` ACTUALLY LIVES IN, not
172
+ # one at `copy`'s own root — `Folder#load_domain` resolves a
173
+ # SINGLE `bluebook_directory` and globs `*.world` there, non-
174
+ # recursively (`Folder#load_each`); a domain can hold several
175
+ # `Hecks.hecksagon "<Name>" do ... end` SIBLING blocks in that one
176
+ # file (banking.hecksagon declares "Banking", "Governance", and
177
+ # "Identity" together), so every name found in one `.hecksagon`
178
+ # file gets bundled into one `.world` written beside it, not
179
+ # scattered by name.
180
+ Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
181
+ names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
182
+ next if names.empty?
183
+
184
+ world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres.world")
185
+ File.write(world_path, names.map { |name|
186
+ <<~WORLD
187
+ Hecks.world "#{name}" do
188
+ persisted_by("Postgres") do
189
+ database "#{FUZZ_POSTGRES_DATABASE}"
190
+ schema "#{FUZZ_POSTGRES_SCHEMA}"
191
+ end
192
+ end
193
+ WORLD
194
+ }.join("\n"))
195
+ end
196
+
197
+ # Any PRE-EXISTING `.world` this copy shipped with (a real
198
+ # deployment's own connection string) is now redundant with — and
199
+ # would conflict with, `Registry#add_world`'s own header on
200
+ # loading the same domain name twice — the fresh one just
201
+ # written above, so it goes the same way `rebind_to_memory!`
202
+ # already sends every `.world` in a Memory-mode boot.
203
+ Dir.glob(File.join(copy, "**", "*.world")).each do |path|
204
+ File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres.world"
205
+ end
206
+ end
207
+
208
+ # ADMIN CONNECTION LIVES OUTSIDE THE TMP COPY ENTIRELY — same as
209
+ # every other real-Postgres spec in this repo (`support/
210
+ # postgres_probe.rb`'s own header). Database created once per
211
+ # process and remembered (`@fuzz_database_ready` on this module's
212
+ # own singleton, the same memoization shape `PostgresProbe
213
+ # .available?` already uses) ; the schema inside it is dropped and
214
+ # recreated on EVERY call, which is what actually isolates one
215
+ # ephemeral boot's data from the next.
216
+ def ensure_fuzz_schema!
217
+ # `Adapters::Postgres#initialize` opens ONE real `PG::Connection`
218
+ # PER AGGREGATE and never explicitly closes it — fine for a
219
+ # process that boots once and runs, exactly what every other
220
+ # caller of this adapter is. A fuzz run boots dozens to hundreds
221
+ # of EPHEMERAL times in one process (every seed does at least a
222
+ # generate + a replay, `replay_is_deterministic` doubles that,
223
+ # shrinking multiplies it further), and `PG::Connection` only
224
+ # actually closes its socket when Ruby's GC finalizes the
225
+ # (by-then-unreferenced) object — which, under a tight loop like
226
+ # this, does not reliably keep pace with how fast new
227
+ # connections open. Discovered live: `bin/fuzz --adapter
228
+ # postgres` against `examples/banking` hit a real local
229
+ # Postgres's own `max_connections` after roughly a dozen
230
+ # ephemeral boots, `PG::ConnectionBad: ... "too many clients
231
+ # already"`. A `GC.start` here — right before the next ephemeral
232
+ # boot's connections open, not on some timer — reclaims every
233
+ # connection the PREVIOUS boot's now-unreferenced adapters held,
234
+ # keeping the live count bounded regardless of run length. This
235
+ # is a real constraint on running Postgres in a loop, not
236
+ # something to route around by connecting less carefully.
237
+ GC.start
238
+
239
+ unless @fuzz_database_ready
240
+ admin = PG.connect(dbname: "postgres")
241
+ exists = admin.exec_params(
242
+ "SELECT 1 FROM pg_database WHERE datname = $1", [FUZZ_POSTGRES_DATABASE]
243
+ ).ntuples.positive?
244
+ admin.exec(%(CREATE DATABASE "#{FUZZ_POSTGRES_DATABASE}")) unless exists
245
+ admin.close
246
+ @fuzz_database_ready = true
247
+ end
248
+
249
+ db = PG.connect(dbname: FUZZ_POSTGRES_DATABASE)
250
+ # QUIET ON PURPOSE — same as `Adapters::Postgres.connect_for`'s
251
+ # own `SET client_min_messages`: a `DROP SCHEMA ... CASCADE` that
252
+ # actually has something to drop (every boot after the first)
253
+ # NOTICEs once per dropped object, which is the ordinary case
254
+ # here, not news, and would otherwise bury `bin/fuzz`'s own
255
+ # output under a wall of "drop cascades to table ..." on every
256
+ # single ephemeral boot.
257
+ db.exec("SET client_min_messages = warning")
258
+ quoted = db.quote_ident(FUZZ_POSTGRES_SCHEMA)
259
+ db.exec("DROP SCHEMA IF EXISTS #{quoted} CASCADE")
260
+ db.exec("CREATE SCHEMA #{quoted}")
261
+ db.close
262
+ end
263
+
264
+ # THE SHARED REWRITE — factored out of `rebind_to_memory!` when
265
+ # Sqlite/Postgres modes needed the identical `.hecksagon` surgery
266
+ # with only the target adapter name differing. `persisted_by`/
267
+ # `projected_by` can be spelled two ways: aggregate-scoped
268
+ # (`Banking::Customer.persisted_by("Heki")`, always parenthesised)
269
+ # and, since §0's domain-level default binds, a bare call at the
270
+ # hecksagon's own root (`persisted_by "Heki"`, no parens, no
271
+ # receiver). Both must be caught here or a domain that only
272
+ # declares the bare form keeps its real adapter under an
273
+ # "isolated" fuzz boot. `projected_by` is dropped outright rather
274
+ # than rebound, for every target adapter — `Registry
275
+ # #read_repository` already falls back to the authoritative
276
+ # repository when none exists, so a read model this ephemeral copy
277
+ # never wires is simply unread, not broken.
278
+ def rewrite_bindings!(copy, adapter_name)
279
+ Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |path|
280
+ lines = File.readlines(path).reject { |line| line.match?(/\bprojected_by\s*\(?\s*"/) }
281
+ File.write(path, lines.join.gsub(/persisted_by\s*\(?\s*"[^"]+"\s*\)?/, "persisted_by(\"#{adapter_name}\")"))
282
+ end
283
+ end
90
284
  end
91
285
  end
92
286
  end
@@ -236,9 +236,9 @@ module Hecks
236
236
  # comparison depended on, anything. Two independent replays, not a
237
237
  # cached one compared to itself, so a bug that corrupts the FIRST
238
238
  # run's own bookkeeping cannot pass by agreeing with itself.
239
- def replay_is_deterministic(domain_path, steps)
240
- first = Replay.call(domain_path, steps)
241
- second = Replay.call(domain_path, steps)
239
+ def replay_is_deterministic(domain_path, steps, adapter: :memory)
240
+ first = Replay.call(domain_path, steps, adapter: adapter)
241
+ second = Replay.call(domain_path, steps, adapter: adapter)
242
242
 
243
243
  comparable = ->(history) { history.reject { |key, _| key == :bluebook || key == :bluebooks } }
244
244
  return true if comparable.call(first) == comparable.call(second)
@@ -257,9 +257,34 @@ module Hecks
257
257
  # drifted before — an adapter that ACCEPTS what the reference says
258
258
  # matches nothing, or orders what it refuses to order, shows up
259
259
  # here as a finding no self-referential adapter spec could see.
260
+ # M23 — `Replay` now runs the native and reference engines
261
+ # INDEPENDENTLY (each in its own begin/rescue — see that file's own
262
+ # comment at the capture site), so this property can tell apart what
263
+ # used to be indistinguishable: "both engines refused" (fine — the
264
+ # ask was genuinely bad, nothing to compare) from "one refused and
265
+ # the other did not" (a real divergence — the two engines disagree
266
+ # about whether the ask was even VALID, never mind what it answers).
267
+ # `native_refused`/`reference_refused` are read by KEY PRESENCE, not
268
+ # truthiness — `Replay` only ever adds `:error`/`:reference_error`
269
+ # to an entry when that side actually raised, so an absent key is an
270
+ # unambiguous "this side answered." A read-model ask (no reference
271
+ # twin attempted at all, `asked[:query]` without "::") is skipped
272
+ # entirely, same as always — there is no second engine to disagree
273
+ # with.
260
274
  def query_answers_match_reference(history)
261
275
  offenders = history.fetch(:queries).filter_map do |asked|
262
- next if asked[:error] || asked[:reference_rows].nil?
276
+ next unless asked[:query].is_a?(String) && asked[:query].include?("::")
277
+
278
+ native_refused = asked.key?(:error)
279
+ reference_refused = asked.key?(:reference_error)
280
+
281
+ if native_refused != reference_refused
282
+ next "#{asked[:query]} #{asked[:args].inspect} — native #{native_refused ? "refused (#{asked[:error]})" : 'answered'}, " \
283
+ "but the reference interpreter #{reference_refused ? "refused (#{asked[:reference_error]})" : 'answered'} — " \
284
+ "a refusal-shaped divergence, not just a differing row set"
285
+ end
286
+
287
+ next if native_refused
263
288
  next if asked[:rows] == asked[:reference_rows]
264
289
 
265
290
  "#{asked[:query]} #{asked[:args].inspect} answered #{asked[:rows].inspect} " \
@@ -462,10 +487,12 @@ module Hecks
462
487
 
463
488
  command = command_for_verb(bluebooks, refusal[:verb])
464
489
  next "#{refusal[:verb]} raised #{refusal[:kind]}, but no declared command resolves that verb" unless command
465
- next if command.guard_descriptions.include?(match[2])
490
+
491
+ declared = effective_guard_descriptions(bluebooks, refusal[:verb], command)
492
+ next if declared.include?(match[2])
466
493
 
467
494
  "#{refusal[:verb]} refused — #{match[2].inspect} — but #{command.hecks_name} declares no given " \
468
- "or ensures with that description (it declares #{command.guard_descriptions.inspect})"
495
+ "or ensures with that description (it declares #{declared.inspect})"
469
496
  end
470
497
 
471
498
  offenders.empty? || offenders.join("; ")
@@ -488,6 +515,25 @@ module Hecks
488
515
  # refusal read as "no declared command resolves that verb" purely
489
516
  # because `history[:bluebook]` was Expression, not Translation; the
490
517
  # refusal was real, this property's own domain resolution was not.
518
+ # A DELEGATING DOOR REFUSES WITH ITS TARGET'S OWN WORDS. `delegates_to`
519
+ # (CommandBuilder#delegates_to_impl) hands the whole dispatch to one
520
+ # entity command, and that command's given is what refuses — raised
521
+ # back through the door, in the door's name (chess: `Game.MoveKnight
522
+ # refused — "it is that color's turn"`, a given Knight.Move declares
523
+ # and MoveKnight, a pure passthrough, never could). Read the door's
524
+ # own guards first, then every delegation target's; an offence is
525
+ # only a description NEITHER declares. Found live mining chess's
526
+ # history: every refused move through a door read as undeclared.
527
+ def effective_guard_descriptions(bluebooks, verb, command)
528
+ own = command.guard_descriptions
529
+ delegated = command.mutations.select { |m| m.op == :delegate }.flat_map do |delegation|
530
+ domain, aggregate_name, = Naming.split_verb(verb)
531
+ target = command_for_verb(bluebooks, "#{domain}::#{aggregate_name}.#{delegation.target}")
532
+ target ? target.guard_descriptions : []
533
+ end
534
+ own + delegated
535
+ end
536
+
491
537
  def command_for_verb(bluebooks, verb)
492
538
  domain, aggregate_name, command_path = Naming.split_verb(verb)
493
539
  return nil unless command_path
@@ -57,12 +57,12 @@ module Hecks
57
57
  # DISPATCH_ORDER.
58
58
  GUARD_REFUSAL_CLASSES = [Runtime::GivenNotMet, Runtime::LifecycleRefused].freeze
59
59
 
60
- def call(domain_path, steps)
60
+ def call(domain_path, steps, adapter: :memory)
61
61
  # See isolated_boot.rb's own header: resets data/ AND rebinds
62
- # persistence to Memory, since a Postgres-bound domain's real store
63
- # lives outside the copied directory and cannot be reached by
64
- # resetting data/ alone.
65
- IsolatedBoot.call(domain_path) do |copy|
62
+ # persistence to the chosen adapter (Memory by default), since a
63
+ # Postgres-bound domain's real store lives outside the copied
64
+ # directory and cannot be reached by resetting data/ alone.
65
+ IsolatedBoot.call(domain_path, adapter: adapter) do |copy|
66
66
  runtime = Hecks.boot(copy)
67
67
 
68
68
  refusals = []
@@ -112,22 +112,55 @@ module Hecks
112
112
  next
113
113
  end
114
114
 
115
+ # THE QUERY ORACLE — TWO INDEPENDENT ENGINES, EACH RUN AND
116
+ # CAUGHT ON ITS OWN, never a single shared `begin`/`rescue`
117
+ # wrapping both calls. A shared begin/rescue meant `runtime.
118
+ # query` raising (native refuses) short-circuited BEFORE
119
+ # `runtime.reference_query` ever ran at all — the entry
120
+ # recorded only `error:`, with no `reference_rows` and no
121
+ # record of what the reference interpreter would have
122
+ # answered — and `runtime.reference_query` raising instead
123
+ # (reference refuses, native already succeeded) landed in the
124
+ # SAME rescue, discarding the native `rows` this begin block
125
+ # had already computed and recording the whole ask as an
126
+ # ordinary refusal. Either way, "one engine refused and the
127
+ # other did not" — a real divergence, exactly the shape a
128
+ # differential oracle exists to catch — read as "refused,
129
+ # nothing to compare" and vanished. Each engine's own
130
+ # success/failure is captured here independently instead, so
131
+ # `Properties.query_answers_match_reference` can tell "both
132
+ # refused" (fine) apart from "one refused, the other did not"
133
+ # (a finding) rather than having every refusal-shaped run
134
+ # skipped uniformly.
135
+ native_rows = native_error = nil
115
136
  begin
116
- rows = runtime.query(question, **args)
117
- # THE QUERY ORACLE'S OTHER HALF — the same ask at the same
118
- # instant, answered by the reference interpreter instead of
119
- # the bound adapter's native hook. Recorded side by side so
120
- # Properties.query_answers_match_reference can treat any
121
- # difference as a finding. Read-model asks (bare domain
122
- # form, no "::") have no reference twin and record only the
123
- # one answer.
124
- reference = question.include?("::") ? runtime.reference_query(question, **args) : nil
125
- queries << { query: question, args: args, rows: rows, reference_rows: reference,
126
- instances_at: snapshot_instances(runtime) }
137
+ native_rows = runtime.query(question, **args)
127
138
  rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
128
- queries << { query: question, args: args, error: e.message }
129
- refusals << { verb: question, error: e.message, kind: e.class.name }
139
+ native_error = e
130
140
  end
141
+
142
+ # Read-model asks (bare domain form, no "::") have no
143
+ # reference twin at all — never attempted, not "attempted and
144
+ # agreed."
145
+ has_reference = question.include?("::")
146
+ reference_rows = reference_error = nil
147
+ if has_reference
148
+ begin
149
+ reference_rows = runtime.reference_query(question, **args)
150
+ rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
151
+ reference_error = e
152
+ end
153
+ end
154
+
155
+ entry = { query: question, args: args, rows: native_rows, instances_at: snapshot_instances(runtime) }
156
+ entry[:error] = native_error.message if native_error
157
+ if has_reference
158
+ entry[:reference_rows] = reference_rows
159
+ entry[:reference_error] = reference_error.message if reference_error
160
+ end
161
+ queries << entry
162
+
163
+ refusals << { verb: question, error: native_error.message, kind: native_error.class.name } if native_error
131
164
  next
132
165
  end
133
166
 
@@ -1,4 +1,5 @@
1
1
  require_relative "../value_generator"
2
+ require_relative "../../naming"
2
3
 
3
4
  module Hecks
4
5
  module Fuzzing
@@ -11,8 +12,7 @@ module Hecks
11
12
 
12
13
  def record_outcome(catalog, entry, args)
13
14
  aggregate = entry[:aggregate]
14
- parent_key = (aggregate.identified_by || :id).to_s
15
- parent_scalar = ValueGenerator.scalar_of(args[parent_key])
15
+ parent_scalar = identity_scalar_of(aggregate, args)
16
16
 
17
17
  @known_ids[aggregate.hecks_name] << parent_scalar if entry[:entity].nil? && entry[:command].creates?
18
18
 
@@ -35,6 +35,32 @@ module Hecks
35
35
  @entity_known_ids[key] << new_id
36
36
  end
37
37
 
38
+ # THE SCALAR THIS STEP'S OWN AGGREGATE IDENTITY RESOLVES TO, from the
39
+ # args a creating (or entity-populating) command actually
40
+ # dispatched — a SINGLE declared head (or the untyped default
41
+ # `:id`) reads straight off its own top-level arg the way this
42
+ # always has (`ValueGenerator.scalar_of`, opening the identity value
43
+ # object). A genuinely COMPOSITE identity (`composite_identity?`,
44
+ # shared with StepBuilder#add_identity! — see its own comment) has
45
+ # no such top-level arg at all: `add_identity!` deliberately leaves
46
+ # a composite creating command's own individually-declared fields
47
+ # alone rather than forcing a synthetic `id` neither the bluebook
48
+ # nor the command ever declared, so this joins those fields itself,
49
+ # in the SAME declaration order `Runtime::Identity.of` joins them
50
+ # at dispatch (`Naming.identity`, `Naming::IDENTITY_JOIN`) — which
51
+ # is exactly what makes the result usable later as the bare `id:`
52
+ # a composite Revoke/act-again command expects (`Identity.from`'s
53
+ # own untyped fallback, reached when `identity_of` finds no
54
+ # top-level field args to join, reads a bare `id:` straight
55
+ # through unattributed).
56
+ def identity_scalar_of(aggregate, args)
57
+ parent_key = (aggregate.identified_by || :id).to_s
58
+ return ValueGenerator.scalar_of(args[parent_key]) unless composite_identity?(aggregate)
59
+
60
+ parts = aggregate.identity_paths.map { |path| ValueGenerator.scalar_of(args[path.to_s.split(".").first]) }
61
+ Naming.identity(parts)
62
+ end
63
+
38
64
  def pick_known(name)
39
65
  pool = @known_ids[name]
40
66
  return ValueGenerator.random_id(@random) if pool.empty? || @random.rand < ValueGenerator::INVALID_REFERENCE_PROBABILITY
@@ -2,6 +2,8 @@ require_relative "../../bluebook/expression/resolver"
2
2
  require_relative "../invalid_value_generator"
3
3
  require_relative "../value_generator"
4
4
  require_relative "../../runtime/errors"
5
+ require_relative "../../runtime/value"
6
+ require_relative "../../naming"
5
7
 
6
8
  module Hecks
7
9
  module Fuzzing
@@ -52,10 +54,6 @@ module Hecks
52
54
 
53
55
  def args_for(attributes, aggregate)
54
56
  args = attributes.each_with_object({}) do |attribute, built|
55
- # List-typed direct command arguments have no example in this repo's
56
- # domains today — every list is populated via a per-element append
57
- # command instead. Skipped rather than guessed at.
58
- next if attribute.list?
59
57
  # AN OPTIONAL ARGUMENT IS SOMETIMES NOT GIVEN, and that is an
60
58
  # ordinary payload rather than a damaged one — see
61
59
  # OPTIONAL_OMITTED_PROBABILITY for why this cannot live in
@@ -63,12 +61,52 @@ module Hecks
63
61
  # exist at all.
64
62
  next if attribute.optional? && @random.rand < SequenceGenerator::OPTIONAL_OMITTED_PROBABILITY
65
63
 
66
- built[attribute.name.to_s] = ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids)
64
+ if attribute.list?
65
+ value = list_value_for(attribute, aggregate)
66
+ # A list-of-ENTITY command attribute has no real example
67
+ # anywhere in this repo's domains — every entity-owned list is
68
+ # populated via a per-element append command instead, never a
69
+ # whole-list command argument — so `list_value_for` (below)
70
+ # answers `nil` for one rather than guessing at an entity's own
71
+ # shape, and this step still skips it exactly as it always
72
+ # has. A list-of-VALUE-OBJECT attribute (`ConsoleSettings::
73
+ # Collection.ReplaceColumns`' own `columns`, `list_of(Column)`)
74
+ # is the real, previously-unfuzzable case this now covers —
75
+ # `sets :columns` imports the owner aggregate's own declared
76
+ # `list_of` attribute onto the command verbatim (Command
77
+ # Builder#resolve_bare_set!), so it is a required, ordinary
78
+ # top-level argument like any other, not an entity mutation.
79
+ next if value.nil?
80
+
81
+ built[attribute.name.to_s] = value
82
+ else
83
+ built[attribute.name.to_s] = ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids)
84
+ end
67
85
  end
68
86
 
69
87
  malform(args, attributes, aggregate)
70
88
  end
71
89
 
90
+ # A `list_of` ATTRIBUTE'S OWN VALUE — an array of independently
91
+ # generated elements, each shaped exactly the way a bare (non-list)
92
+ # attribute of the SAME declared element type already is
93
+ # (`ValueGenerator.value_for`), since `list_of(X)`'s own element
94
+ # coercion is `X`'s ordinary shape repeated, not a different one
95
+ # (`Attribute#type` is already unwrapped from `list_of(...)` at
96
+ # declare time — the same fact `command_builder.rb`'s own comment
97
+ # on `resolve_bare_set!` names). 0-3 elements — enough to exercise a
98
+ # genuinely non-trivial replace without generating pathologically
99
+ # large payloads every time. `nil`, not `[]`, when the element type
100
+ # isn't a declared value object at all (an entity-typed list) — see
101
+ # `args_for`'s own comment on why that case still skips rather than
102
+ # guesses.
103
+ def list_value_for(attribute, aggregate)
104
+ value_object = Runtime::Value.value_object_for(aggregate, attribute.type.to_s)
105
+ return nil unless value_object
106
+
107
+ Array.new(@random.rand(0..3)) { ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids) }
108
+ end
109
+
72
110
  # ONE MALFORMATION AT A TIME, and usually none. A step whose payload is
73
111
  # wrong in three ways only ever proves which check runs first ; wrong in
74
112
  # exactly one way names the check that fired. And the rate stays low on
@@ -120,13 +158,40 @@ module Hecks
120
158
  entity_scalar = pick_entity_known(aggregate.hecks_name, entry[:entity].hecks_name, parent_scalar)
121
159
  args[entity_key] = identity_shaped(entry[:entity], entry[:entity].identified_by, entity_scalar, aggregate)
122
160
  elsif entry[:command].creates?
123
- args[parent_key] ||= identity_shaped(aggregate, aggregate.identified_by, ValueGenerator.random_id(@random), aggregate)
161
+ # A COMPOSITE IDENTITY (`identified_by` answering nil with MORE
162
+ # THAN ONE declared path — Behaviour::Identified's own "a
163
+ # composite has no single head" comment) supplies every one of
164
+ # its parts as its own ordinary, individually-declared command
165
+ # attribute already — `RoleAssignment::Assign` takes actor_id/
166
+ # role_name/starts_at directly, `args_for` (above) already
167
+ # generated all three. Forcing a synthetic top-level `id` here
168
+ # too — this codebase's own fallback for the SINGLE-key and the
169
+ # genuinely untyped (`identity_paths.empty?`, no `identified_by`
170
+ # declared at all) cases — hands a composite creating command
171
+ # an argument it never declared at all, refused every time as
172
+ # unknown before this check existed (a creating `Assign`/`Grant`
173
+ # step was never anything BUT refused). `identity_paths.empty?`
174
+ # is the untyped default (falls all the way back to a minted
175
+ # `:id` the runtime itself never declared as an attribute
176
+ # either), which still needs exactly the old minting behavior.
177
+ unless composite_identity?(aggregate)
178
+ args[parent_key] ||= identity_shaped(aggregate, aggregate.identified_by, ValueGenerator.random_id(@random), aggregate)
179
+ end
124
180
  else
125
181
  scalar = pick_known(aggregate.hecks_name)
126
182
  args[parent_key] = identity_shaped(aggregate, aggregate.identified_by, scalar, aggregate)
127
183
  end
128
184
  end
129
185
 
186
+ # TRUE ONLY FOR A GENUINE MULTI-FIELD IDENTITY — `identified_by`
187
+ # returns nil both for a real composite (`identity_paths.size > 1`)
188
+ # and for the untyped default with NO identity declared at all
189
+ # (`identity_paths.size == 0`, Behaviour::Identified's own
190
+ # `Array(@identified_by)` fallback) ; only the first of those two
191
+ # has its own parts already sitting in `args` as real, individually-
192
+ # generated command attributes.
193
+ def composite_identity?(aggregate) = aggregate.identified_by.nil? && aggregate.identity_paths.size > 1
194
+
130
195
  # A bare scalar id, shaped to match whatever `construct` itself
131
196
  # declares that identity field as. `Account::LedgerEntry` is addressed
132
197
  # by `sequence`, and `sequence` is declared as a value-object-typed
@@ -73,8 +73,8 @@ module Hecks
73
73
  # the generating end rather than the scoring end.
74
74
  UNEXERCISED_WEIGHT = 4
75
75
 
76
- def self.generate(domain_path, seed:, steps:)
77
- new(domain_path, seed: seed, steps: steps).call
76
+ def self.generate(domain_path, seed:, steps:, adapter: :memory)
77
+ new(domain_path, seed: seed, steps: steps, adapter: adapter).call
78
78
  end
79
79
 
80
80
  # How many EVENTS the generated sequence actually produced — not
@@ -87,10 +87,11 @@ module Hecks
87
87
  # replay one.
88
88
  attr_reader :event_count
89
89
 
90
- def initialize(domain_path, seed:, steps:)
90
+ def initialize(domain_path, seed:, steps:, adapter: :memory)
91
91
  @domain_path = domain_path
92
92
  @seed = seed
93
93
  @step_count = steps
94
+ @adapter = adapter
94
95
  @random = Random.new(seed)
95
96
  @known_ids = Hash.new { |h, k| h[k] = [] }
96
97
  @entity_known_ids = Hash.new { |h, k| h[k] = [] }
@@ -106,7 +107,7 @@ module Hecks
106
107
  # a Postgres-bound domain's real store lives outside the copied
107
108
  # directory entirely and `rm_rf`ing data/ alone cannot reach it —
108
109
  # see isolated_boot.rb's own header.
109
- IsolatedBoot.call(@domain_path) do |copy|
110
+ IsolatedBoot.call(@domain_path, adapter: @adapter) do |copy|
110
111
  runtime = Hecks.boot(copy)
111
112
  catalog = build_catalog(runtime)
112
113
  Array.new(@step_count) { attempt_step(runtime, catalog) }.compact