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
@@ -8,16 +8,39 @@ module Hecks
8
8
  # `lib/hecks/language/bluebook/` declares what a bluebook IS —
9
9
  # Chapter, Root, Verb, Shape, Ask, Piece, and the rest, split across files
10
10
  # by domain concept and merged into one chapter at load time (see
11
- # GRAMMAR_FILES below) and carries the language's rules as `given` and
12
- # `invariant` rather than as `raise Malformed` scattered across seven
13
- # builder files. This replays a built IR into that domain and turns any
14
- # refusal into a Malformed, so the meta-domain is what actually judges
15
- # rather than a description sitting beside the code.
11
+ # GRAMMAR_FILES below). This replays a built IR into that domain and turns
12
+ # any refusal into a Malformed, so the meta-domain is what actually judges
13
+ # rather than a description sitting beside the code for whatever rules
14
+ # it carries. `spec/meta_rules_spec.rb`'s own header names the plan: port
15
+ # the language's rules OUT of builder `raise Malformed` calls and INTO
16
+ # `given`/`invariant` here, where they are declarations any reader of the
17
+ # meta-domain can consume instead of behavior buried in a builder.
16
18
  #
17
- # Delete language/bluebook/ and validation stops. That is the whole
18
- # point : a self-description that only describes is indistinguishable from
19
- # enforcement, and the first version of this file was deleted for exactly
20
- # that reason.
19
+ # THIS MIGRATION IS PARTIAL, NOT DONE. As of this writing the meta-domain
20
+ # declares 62 given/invariant/ensures rules (`Hecks::QueryIR.collect_rules`
21
+ # against `grammar_registry.bluebook("Bluebook")` enumerates them)
22
+ # `spec/meta_rule_reachability_spec.rb` is what proves, per declaration,
23
+ # not per verb, that most of them still lack a spec exercising the
24
+ # refusal at all (see that file's own KNOWN_GAPS for the current count).
25
+ # Meanwhile `lib/hecks/bluebook/dsl/` still carries well over a hundred
26
+ # `raise Malformed` calls of its own — some are genuinely pre-IR
27
+ # construction errors (arity, argument shape) that cannot become
28
+ # meta-domain rules, and some are exactly the semantic kind this file
29
+ # claims to have moved (see `aggregate_builder.rb`'s `seal_*` passes:
30
+ # a mutation into a field the aggregate never declares, a lifecycle guard
31
+ # on an aggregate with no lifecycle). No doc currently inventories which
32
+ # is which, or tracks migrating the latter — that inventory is the actual
33
+ # next step, not a "delete the folder" thought experiment.
34
+ #
35
+ # So: "delete language/bluebook/ and validation stops" is true for the 62
36
+ # rules actually declared here, and false for whatever a builder's own
37
+ # `raise Malformed` still checks — the language does not yet own its own
38
+ # enforcement end to end, and this comment used to claim it already did.
39
+ # The self-hosting mechanism itself is real and is the point worth
40
+ # keeping : a self-description that only describes is indistinguishable
41
+ # from enforcement, and the first version of this file was deleted for
42
+ # exactly that reason. What is not yet real is that self-hosting being
43
+ # the WHOLE of validation.
21
44
  #
22
45
  # The meta-domain is loaded ONCE and its registry reused ; each bluebook is
23
46
  # judged in a fresh in-memory store so no domain can see another's records.
@@ -131,6 +154,12 @@ module Hecks
131
154
  # dead code, never actually invoked.
132
155
  builder = registry.bluebook_builder(name) { raise "internal: no open builder for #{name}" }
133
156
  builder.resolve_pending_chapter_givens!
157
+ # THE ENTITY-SCOPED ANALOGUE, one level down — same reason,
158
+ # same timing: a bare entity-level given left PENDING by any
159
+ # file of this chapter (`EntityBuilder#pending_chapter_entity_
160
+ # given`) must resolve before anything below reads a piece's
161
+ # own `Given` fields too.
162
+ builder.resolve_pending_chapter_entity_givens!
134
163
 
135
164
  # `BluebookBuilder#build` skipped its own whole-chapter battery
136
165
  # (hops, projected fields, correlation keys, event shapes,
@@ -147,7 +176,48 @@ module Hecks
147
176
  end
148
177
  end
149
178
 
150
- def self.disabled? = ENV["HECKS_META_VALIDATION"] == "off"
179
+ # `&& !@forcing_fixpoint` see `while_forcing_fixpoint` below, whose own
180
+ # window must win even while a growth spec's `while_disabled` is open,
181
+ # for the reason recorded there. Otherwise the SAME stack-restore shape
182
+ # `while_shadow_parsing`/`while_forcing_fixpoint` use, not a bare env
183
+ # toggle any more — it used to be exactly that (`ENV["HECKS_META_
184
+ # VALIDATION"] == "off"`, read directly, with no `previous`/`ensure` of
185
+ # its own), and the gap between "bare toggle" and "stack-restore" was
186
+ # not cosmetic: a test's temporary window could reach code it was never
187
+ # meant to touch. If `grammar_registry`'s ONE-TIME lazy build (below)
188
+ # happened to land inside that window, EVERY language chapter got
189
+ # cached in its raw, never-judged form for the rest of the process —
190
+ # `unmark_scalar`'s String->Integer/Boolean fix (assembly/marks.rb)
191
+ # never ran, so a `Command`'s own `required: true` stayed
192
+ # `required: "true"` forever after, permanently memoized. Found live:
193
+ # an intermittent, parallel_rspec-only ir_golden_spec.rb failure,
194
+ # order-dependent on whether identifier_numeric_coercion_growth_spec.rb's
195
+ # disabled-validation window raced the ONE lazy build in its own worker
196
+ # process — reproduced in isolation by disabling validation before the
197
+ # first `grammar_registry` call. `&& !@forcing_fixpoint` was the first
198
+ # fix and is kept ; converting `@disabled` itself to this shape closes
199
+ # the gap for every OTHER caller of `while_disabled`, not just the one
200
+ # race that was actually observed — nothing outside this file reads
201
+ # `ENV["HECKS_META_VALIDATION"]` any more (confirmed: every one of the
202
+ # dozen growth specs that used to hand-roll `previous = ENV[...] ;
203
+ # ENV[...] = "off" ; ... ; ensure ENV[...] = previous` now calls
204
+ # `while_disabled` instead), so there is no bare global left to race.
205
+ def self.disabled? = @disabled && !@forcing_fixpoint
206
+
207
+ # THE SAME STACK-RESTORE SHAPE `while_shadow_parsing`/`while_forcing_
208
+ # fixpoint` USE. This toggle's real, intended use is a growth spec
209
+ # that boots a scratch bluebook from a tempfile and wants the runtime
210
+ # behaviour without the validation overhead ; that is always a single
211
+ # bounded window around one boot, never a flag meant to survive past
212
+ # it, so the flag itself is scoped in the same `previous`/`ensure`
213
+ # shape rather than a plain assignment a caller could forget to undo.
214
+ def self.while_disabled
215
+ previous = @disabled
216
+ @disabled = true
217
+ yield
218
+ ensure
219
+ @disabled = previous
220
+ end
151
221
 
152
222
  # ADR 0025's own prerequisite (docs/dsl-work-slices.md, S0a): a word
153
223
  # a later slice removes from the LIVE grammar must still parse
@@ -175,6 +245,21 @@ module Hecks
175
245
  @shadow_parsing = previous
176
246
  end
177
247
 
248
+ # THE SAME STACK-RESTORE SHAPE `while_shadow_parsing` USES, for the
249
+ # same reason: whatever this wraps must never see `disabled?` answer
250
+ # true, however a test elsewhere has the env toggle set at that
251
+ # exact moment. Only `grammar_registry`'s own one-time build (below)
252
+ # wraps itself in this — nothing else needs it, and nothing else
253
+ # should reach for it just to dodge `disabled?` for a domain
254
+ # bluebook, which is precisely the toggle's real, intended use.
255
+ def self.while_forcing_fixpoint
256
+ previous = @forcing_fixpoint
257
+ @forcing_fixpoint = true
258
+ yield
259
+ ensure
260
+ @forcing_fixpoint = previous
261
+ end
262
+
178
263
  # The same bluebook judged twice gets the same verdict, and a suite reloads
179
264
  # its fixtures constantly — banking alone is ~200 dispatches per build.
180
265
  # Keyed on the IR itself, so a CHANGED bluebook is always re-judged.
@@ -348,9 +433,14 @@ module Hecks
348
433
  # judged from here on is judged by the language the language itself
349
434
  # produced. Outside load_grammar_into on purpose : its ensure clears
350
435
  # @bootstrapping, and call() must see bootstrapping? == false to do
351
- # anything at all.
352
- LANGUAGE_CHAPTERS.each { |name| registry.add_bluebook(call(registry.bluebook(name))) }
353
- load_attached_grammar_into(registry)
436
+ # anything at all. `while_forcing_fixpoint`-wrapped so a growth
437
+ # spec's own `while_disabled` window can never leave this
438
+ # ONE-TIME build cached in its raw, never-judged form — see
439
+ # `disabled?`'s own comment.
440
+ while_forcing_fixpoint do
441
+ LANGUAGE_CHAPTERS.each { |name| registry.add_bluebook(call(registry.bluebook(name))) }
442
+ load_attached_grammar_into(registry)
443
+ end
354
444
  # Stamped LAST, keyed by this registry's own identity rather than
355
445
  # a bare boolean — a manual reset (fixpoint_spec.rb's own
356
446
  # `@grammar_registry = nil`) makes @grammar_registry not equal
@@ -46,18 +46,51 @@ module Hecks
46
46
  # states), so a state nothing ever transitions into or out of no
47
47
  # longer exists to be unreachable — the finding this allowlisted
48
48
  # cannot occur any more, by construction.
49
- ALLOWED_FINDINGS = {}.freeze
49
+ #
50
+ # "banking"/NotifyOnClosure, FlagKeyReturn — real, confirmed
51
+ # findings, not bugs to fix. `across "Notifications"` names a
52
+ # domain that does not exist anywhere in this repo — no
53
+ # Notifications bluebook, no hecksagon, nothing to `uses_framework`
54
+ # or `subscribe` to. This is deliberate: `spec/runtime/policy_spec.
55
+ # rb` (a test literally named "records a reaction it cannot
56
+ # deliver rather than swallowing it") and `lib/hecks/runtime/
57
+ # errors.rb`'s own `UnknownVerb` comment both treat "target domain
58
+ # not loaded" as the EXPECTED outcome for it — Notifications is
59
+ # used on purpose to exercise the undelivered-reaction runtime
60
+ # path, not left half-built. There is no real `subscribe` line to
61
+ # add (no event of Notifications' own to name) and no real domain
62
+ # to point `uses_framework` at.
63
+ ALLOWED_FINDINGS = {
64
+ "banking" => [
65
+ [:unacknowledged_relationship, "NotifyOnClosure"],
66
+ [:unknown_target_domain, "NotifyOnClosure"],
67
+ [:unacknowledged_relationship, "FlagKeyReturn"],
68
+ [:unknown_target_domain, "FlagKeyReturn"]
69
+ ]
70
+ }.freeze
50
71
 
51
72
  module_function
52
73
 
53
- def call(bluebook)
74
+ # `hecksagon:`/`known_domains:` — both optional, both `nil`-safe
75
+ # (every existing caller with no sibling hecksagon, or checking one
76
+ # domain in isolation, behaves exactly as before). `hecksagon` is
77
+ # THIS bluebook's own sibling wiring file, if the caller loaded one
78
+ # (see `emitted_events`'s own comment on why a caller that didn't
79
+ # simply finds none, correctly). `known_domains` is the caller's
80
+ # OWN corpus-wide view — every bluebook/hecksagon name it has
81
+ # booted anywhere, across every domain it has looked at, not just
82
+ # this one — used only to catch a typo'd `across`/`uses_framework`
83
+ # target; see `cross_domain_policy_findings`'s own comment for why
84
+ # this can only ever be a corpus-scoped heuristic, never a general
85
+ # correctness guarantee.
86
+ def call(bluebook, hecksagon: nil, known_domains: nil)
54
87
  findings = []
55
88
  bluebook.aggregates.each do |aggregate|
56
89
  findings.concat(lifecycle_findings(aggregate, aggregate))
57
90
  aggregate.entities.each { |entity| findings.concat(lifecycle_findings(aggregate, entity)) }
58
91
  end
59
92
  bluebook.process_managers.each { |pm| findings.concat(saga_findings(bluebook, pm)) }
60
- bluebook.policies.each { |policy| findings.concat(policy_findings(bluebook, policy)) }
93
+ bluebook.policies.each { |policy| findings.concat(policy_findings(bluebook, policy, hecksagon, known_domains)) }
61
94
  findings
62
95
  end
63
96
 
@@ -200,6 +233,23 @@ module Hecks
200
233
  "declares no command at — cross-domain dispatch is out of this " \
201
234
  "checker's scope, same as CommandRules#resolve_references")
202
235
  end
236
+
237
+ # A `compensates` DECLARED WITH NOWHERE TO EVER FIRE — the exact
238
+ # shape of the real bug this whole feature closes ("the
239
+ # reversal was written and never armed"), caught at build/
240
+ # model-check time instead of discovered in production. No
241
+ # handler anywhere answers REFUSED (`pm.saga?` false) means
242
+ # `SagaInterpreter#unwind` never runs for this process
243
+ # manager at all, so a declared `compensates` is structurally
244
+ # unreachable — not a warning about style, a dead declaration.
245
+ if !pm.saga? && handler.dispatches.any?(&:compensates)
246
+ handler.dispatches.select(&:compensates).each do |dispatch|
247
+ findings << Finding.new(kind: :unarmed_compensation, severity: :error, subject: pm.name,
248
+ message: "#{dispatch.command_name} compensates #{dispatch.compensates.command_name}, " \
249
+ "but no handler anywhere in this saga answers a refusal — the " \
250
+ "compensation is declared and can never fire")
251
+ end
252
+ end
203
253
  end
204
254
 
205
255
  if pm.saga? && !reached.include?(pm.saga.from_state)
@@ -240,8 +290,8 @@ module Hecks
240
290
 
241
291
  # ── policies ───────────────────────────────────────────────────────
242
292
 
243
- def policy_findings(bluebook, policy)
244
- return [] if policy.target_domain # cross-domain: CommandRules skips it too; so does this checker.
293
+ def policy_findings(bluebook, policy, hecksagon, known_domains)
294
+ return cross_domain_policy_findings(policy, hecksagon, known_domains) if policy.target_domain
245
295
 
246
296
  emitted = emitted_events(bluebook)
247
297
  findings = []
@@ -276,6 +326,83 @@ module Hecks
276
326
  findings
277
327
  end
278
328
 
329
+ # ── cross-domain policies (Context Mapping) ───────────────────────
330
+ #
331
+ # `uses_framework "X"` already IS a Shared Kernel relationship — it
332
+ # merges X's own bluebook into THIS registry, no boundary. A cross-
333
+ # domain `policy ... across: "X"` already IS a Customer/Supplier
334
+ # relationship — it dispatches into X over real cross-Lambda RPC in
335
+ # the Rust host (`rust/host/src/lambda_client.rs`). Neither is a new
336
+ # word; this makes the CHOICE between them checked instead of a
337
+ # prose comment nobody enforces (`examples/banking/bluebook/
338
+ # banking.hecksagon`'s own hand-written note explaining why
339
+ # Compliance is reached via `across`, never `uses_framework`).
340
+ #
341
+ # NO NEW KEYWORD ANYWHERE — ADR 0025 principle 1 ("one idea, one
342
+ # spelling") refuses a `relationship:`/`as:` argument that would
343
+ # just restate, as a string, the fact the chosen keyword (
344
+ # `uses_framework` vs `across`) already states completely. The
345
+ # DDD vocabulary (Shared Kernel, Customer/Supplier) lives here, in
346
+ # the finding's own name and this comment, and in prose docs — not
347
+ # in the grammar.
348
+ def cross_domain_policy_findings(policy, hecksagon, known_domains)
349
+ return [] unless hecksagon # no sibling hecksagon loaded — nothing to check a relationship against.
350
+
351
+ target = policy.target_domain
352
+ findings = []
353
+
354
+ if hecksagon.framework_members.include?(target)
355
+ # SHARED KERNEL AND CUSTOMER/SUPPLIER ARE MUTUALLY EXCLUSIVE
356
+ # CLAIMS about the SAME target — `uses_framework` means "X is
357
+ # loaded in-process, right here"; `across` means "X is a
358
+ # separate deployment, reached only by RPC." Declaring both is
359
+ # either a pointless RPC to a domain already local, or a
360
+ # `uses_framework` that isn't really doing what its name says.
361
+ findings << Finding.new(kind: :contradictory_relationship, severity: :error, subject: policy.name,
362
+ message: "across #{target.inspect} dispatches over RPC (Customer/Supplier), " \
363
+ "but this hecksagon also uses_framework #{target.inspect} (Shared " \
364
+ "Kernel) — #{target} is already loaded in-process here, so the two " \
365
+ "relationship declarations contradict each other for the same " \
366
+ "target domain")
367
+ elsif hecksagon.subscriptions.none? { |subscribed| Naming.qualifier(subscribed) == target }
368
+ # THIS IS WHAT FINALLY GIVES `subscribe` REAL TEETH — checked
369
+ # here, at model-check time, still never routed at runtime
370
+ # (nothing dispatches off a `subscribe` line; see hecksagon.md's
371
+ # own "checked, not routed" section). ADR 0025 names `subscribe`
372
+ # by number as failing the corpus-use bar; this is the real use.
373
+ findings << Finding.new(kind: :unacknowledged_relationship, severity: :error, subject: policy.name,
374
+ message: "across #{target.inspect} declares a Customer/Supplier " \
375
+ "relationship, but nothing in this hecksagon records the " \
376
+ "expectation — add subscribe \"#{target}.SomeEvent\" for what " \
377
+ "you expect back from it, or uses_framework #{target.inspect} to " \
378
+ "attach it in-process instead")
379
+ end
380
+
381
+ # TYPO DETECTION, DELIBERATELY WEAKER — `known_domains` can only
382
+ # ever be a MONOREPO-SCOPED heuristic: a real external hecks
383
+ # consumer's own domain (this repo's own embryonaut/lifeadelics-
384
+ # shaped case) lives in a genuinely separate repository this
385
+ # corpus scan can never see, so a target this check cannot find
386
+ # is "unknown to THIS corpus," never proof of a typo. Two real,
387
+ # legitimate reasons a target is unresolvable — genuinely
388
+ # undefined by design (the corpus's own "Notifications," used
389
+ # deliberately to exercise the undelivered-reaction runtime path)
390
+ # and real-but-external (a separate repository) — both go in
391
+ # `ALLOWED_FINDINGS`, the same judged-exception mechanism this
392
+ # file already uses for ExternalSettlement, rather than a new
393
+ # keyword invented to declare "this one's fine."
394
+ if known_domains && !known_domains.include?(target)
395
+ findings << Finding.new(kind: :unknown_target_domain, severity: :error, subject: policy.name,
396
+ message: "across #{target.inspect} names a domain nowhere in the corpus " \
397
+ "this check has booted — a typo, or a real domain intentionally " \
398
+ "outside this corpus (undefined by design, or living in a " \
399
+ "separate repository) belongs in ALLOWED_FINDINGS, named and " \
400
+ "explained, not silently assumed correct")
401
+ end
402
+
403
+ findings
404
+ end
405
+
279
406
  # ── shared enumeration ────────────────────────────────────────────
280
407
 
281
408
  # A PORT OPERATION EMITS TOO — the primary/driving port an adapter
@@ -66,6 +66,14 @@ module Hecks
66
66
  def validate(pattern)
67
67
  chars = pattern.to_s.chars
68
68
  index = 0
69
+ # A CHARACTER-CLASS INTERIOR IS A DIFFERENT ALPHABET : inside `[...]`,
70
+ # `*`, `+`, `?`, `(`, `?` are literal characters, not quantifiers or
71
+ # group syntax — `[*+]` means "a literal asterisk or plus". `]` is
72
+ # only the class's close when it isn't the first character after `[`
73
+ # or `[^` (where it is itself a literal, per POSIX bracket-expression
74
+ # rules).
75
+ in_class = false
76
+ class_start = nil
69
77
 
70
78
  while index < chars.length
71
79
  if chars[index] == "\\"
@@ -78,14 +86,36 @@ module Hecks
78
86
  next
79
87
  end
80
88
 
89
+ if in_class
90
+ if chars[index] == "]" && index != class_start
91
+ in_class = false
92
+ index += 1
93
+ next
94
+ end
95
+
96
+ return refuse(:posix_class) if posix_class_at?(chars, index)
97
+
98
+ index += 1
99
+ next
100
+ end
101
+
102
+ if chars[index] == "["
103
+ return refuse(:posix_class) if posix_class_at?(chars, index)
104
+
105
+ in_class = true
106
+ class_start = index + 1
107
+ class_start += 1 if chars[class_start] == "^"
108
+ index += 1
109
+ next
110
+ end
111
+
81
112
  if chars[index] == "(" && chars[index + 1] == "?"
82
113
  return refuse(:lookahead) if %w[= !].include?(chars[index + 2])
83
114
  return refuse(:lookbehind) if chars[index + 2] == "<" && %w[= !].include?(chars[index + 3])
84
115
  return refuse(:atomic_group) if chars[index + 2] == ">"
85
116
  end
86
117
 
87
- return refuse(:posix_class) if posix_class_at?(chars, index)
88
- return refuse(:possessive) if %w[* + ?].include?(chars[index]) && chars[index + 1] == "+"
118
+ return refuse(:possessive) if possessive_at?(chars, index)
89
119
 
90
120
  index += 1
91
121
  end
@@ -115,6 +145,40 @@ module Hecks
115
145
  cursor += 1 while chars[cursor]&.match?(/[a-zA-Z]/)
116
146
  chars[cursor] == ":" && chars[cursor + 1] == "]"
117
147
  end
148
+
149
+ # A possessive quantifier is `*+`, `++`, `?+`, or a bounded `{n}`/{n,m}`
150
+ # immediately followed by `+` — only checked OUTSIDE a character class,
151
+ # where `*`, `+`, `?`, `{`, `}` are quantifier syntax rather than
152
+ # literal characters.
153
+ def possessive_at?(chars, index)
154
+ return true if %w[* + ?].include?(chars[index]) && chars[index + 1] == "+"
155
+ return false unless chars[index] == "{"
156
+
157
+ len = bounded_quantifier_length(chars, index)
158
+ !len.nil? && chars[index + len] == "+"
159
+ end
160
+
161
+ # Length of a `{n}` / `{n,}` / `{n,m}` bound starting at `index`, or nil
162
+ # if what's there isn't one.
163
+ def bounded_quantifier_length(chars, index)
164
+ cursor = index + 1
165
+ digit_seen = false
166
+
167
+ while chars[cursor]&.match?(/[0-9]/)
168
+ digit_seen = true
169
+ cursor += 1
170
+ end
171
+ return nil unless digit_seen
172
+
173
+ if chars[cursor] == ","
174
+ cursor += 1
175
+ cursor += 1 while chars[cursor]&.match?(/[0-9]/)
176
+ end
177
+
178
+ return nil unless chars[cursor] == "}"
179
+
180
+ cursor - index + 1
181
+ end
118
182
  end
119
183
  end
120
184
  end
@@ -3,7 +3,16 @@ require_relative "../ir"
3
3
 
4
4
  module Hecks
5
5
  module Bluebook
6
- DispatchSpec = Struct.new(:command_name, :with_spec, keyword_init: true) do
6
+ # `compensates` a SECOND `DispatchSpec`, shape-identical to this
7
+ # one, naming the command that undoes THIS dispatch specifically
8
+ # (see `ProcessManagerBuilder::HandlerBuilder#dispatch_impl`'s own
9
+ # comment). `nil` for a dispatch with nothing to undo (a pure
10
+ # bookkeeping mark, or one whose own effect is superseded by a later
11
+ # command rather than needing its own compensation). Never nested
12
+ # further — a compensation is not itself compensable; no known
13
+ # corpus need, and ADR 0025's own "a word earns its place by being
14
+ # used" bar would refuse a second level speculatively.
15
+ DispatchSpec = Struct.new(:command_name, :with_spec, :compensates, keyword_init: true) do
7
16
  # A Struct already answers to_h; including the mixin puts the
8
17
  # DECLARED emission ahead of Struct's own in the ancestry, which
9
18
  # is what makes the shape data rather than a method body.
@@ -11,7 +20,8 @@ module Hecks
11
20
 
12
21
  emits_ir(
13
22
  command_name: -> { command_name.to_s },
14
- with_spec: -> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } }
23
+ with_spec: -> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } },
24
+ compensates: one(:compensates)
15
25
  )
16
26
  end
17
27
 
@@ -35,14 +45,31 @@ module Hecks
35
45
  # word — so here they are two objects, and a procedure either has a saga or
36
46
  # does not.
37
47
  #
38
- # `undoes` is the ordered list of commands the compensation sends. Today that
39
- # order is the AUTHOR's, written by hand in one `on :refused` leg, and the
40
- # runtime does not know which legs actually completed. When compensation
41
- # moves beside each dispatch — `reverses` on the step it reverses this is
42
- # where the completed ones, newest first, will live. The shape is already
43
- # right for it; only the source of the order changes.
44
- Saga = Struct.new(:trigger, :from_state, :to_state, :reversals, keyword_init: true) do
45
- def undoes = reversals.map(&:command_name)
48
+ # `undoes` is the ordered list of commands the compensation sends a
49
+ # STATIC PREVIEW, declaration order (`Behaviour::ProcessManager#saga`),
50
+ # not one instance's own runtime history. Per-dispatch compensation
51
+ # (`compensates`, on the step it compensates for) moved most of what
52
+ # a saga undoes off this leg's own hand-written body and onto
53
+ # whichever forward dispatch each one undoes this reads every
54
+ # declared `compensates` across the WHOLE saga first, then whatever
55
+ # this leg's own hand-written body still lists, for compensation
56
+ # that isn't expressible as "undo command X." WHICH of a declared
57
+ # `compensates` actually fires for one instance, and in what order
58
+ # (newest-first, completed-legs-only), is `SagaInterpreter`'s own
59
+ # dynamic `completed_compensations` — a per-instance runtime fact
60
+ # this declaration-only object could never hold.
61
+ #
62
+ # NAMING COLLISION, ONCE FLAGGED, NOW RESOLVED — `command`'s own
63
+ # `corrects event, reverses: true` (docs/implemented/decisions/0036-
64
+ # corrects-is-an-appended-fact-not-a-rewrite.md) already claimed
65
+ # `reverses` for a different meaning: auto-deriving a command's OWN
66
+ # corrective mutation from a past EVENT, not a saga's own
67
+ # compensating leg from a past DISPATCH. This feature keeps
68
+ # `reverses` reserved for `corrects` and uses `compensates` for
69
+ # per-dispatch saga compensation instead — a deliberate choice, not
70
+ # an accidental collision.
71
+ Saga = Struct.new(:trigger, :from_state, :to_state, :compensations, keyword_init: true) do
72
+ def undoes = compensations.map(&:command_name)
46
73
 
47
74
  def to_s = "#{trigger} → #{to_state} (#{undoes.join(', ')})"
48
75
  end
@@ -63,7 +90,22 @@ module Hecks
63
90
 
64
91
  emits_ir(
65
92
  name: :name,
66
- correlates_by: -> { correlates_by.to_s },
93
+ # M11 `&.`, not `.`: a DSL-built process manager always carries
94
+ # a real `correlates_by` (`ProcessManagerBuilder#build` refuses to
95
+ # mint one without it), but the IR class itself defaults it to
96
+ # `nil` and is what `Assembly::Build`'s `:identity` reader
97
+ # (`value&.to_sym`) round-trips against. A bare `.to_s` mapped
98
+ # that absent case to `""`, indistinguishable on the wire from a
99
+ # real empty name and read back as the wrong, non-nil `:""`
100
+ # instead of `nil` — the same nil-erasure S1 fixed for
101
+ # `render_value`, one field over. `correlates_by` is always a
102
+ # bare Symbol (`SagaInterpreter` hash-looks-up a payload by it),
103
+ # never a `Literal`-encoded polymorphic value, so this stays a
104
+ # local `&.` rather than routing through `Literal.render` — that
105
+ # would wrap a real value in a leading `:` and break both the
106
+ # `:identity` reader's plain `to_sym` and the pinned golden IR
107
+ # fixtures' bare-string spelling (`"reference.value"`).
108
+ correlates_by: -> { correlates_by&.to_s },
67
109
  starts_on: :starts_on,
68
110
  ends_on: :ends_on,
69
111
  states: :states,
@@ -30,7 +30,15 @@ module Hecks
30
30
  attributes: many(:attributes),
31
31
  invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical } } },
32
32
  closed_set: :closed_set?,
33
- members: -> { members.map { |member| member.map { |field, value| [field.to_s, value.to_s] } } }
33
+ # THE FIELD NAME IS STRINGIFIED, NEVER THE VALUE. A `member` row can
34
+ # hold any of the scalar types an attribute declares — `Integer 84`
35
+ # (`StatementFrequency#retention_months`, statements.bluebook), not
36
+ # only `String` — and `value.to_s` used to erase that on the way
37
+ # out, so `84` and `"84"` (a member some other row might
38
+ # legitimately spell as text) became indistinguishable once they
39
+ # reached `to_h`. The declared name still moves (`field.to_s`) —
40
+ # that half was never a Ruby object with a type to lose.
41
+ members: -> { members.map { |member| member.map { |field, value| [field.to_s, value] } } }
34
42
  )
35
43
 
36
44
  class << self
@@ -257,7 +257,8 @@ module Hecks
257
257
  "guides" => guide_index(root),
258
258
  "reference" => reference_index(root),
259
259
  "tools" => tool_table(root),
260
- "corpus" => corpus_roster(root)
260
+ "corpus" => corpus_roster(root),
261
+ "diagrams" => diagram_showcase(root)
261
262
  }
262
263
  end
263
264
 
@@ -311,6 +312,26 @@ module Hecks
311
312
  text.length > 140 ? "#{text[0, 137]}..." : text
312
313
  end
313
314
 
315
+ # ONE REAL, COMMITTED FILE, READ FRESH — not re-derived from a boot
316
+ # (this module never requires `hecks/projections/diagrams`, and
317
+ # shouldn't just to draw one example). `docs/generated/diagrams/`
318
+ # is already held to the declaration by `spec/diagrams_spec.rb`'s
319
+ # own drift check; this just quotes its own output, so the two
320
+ # can't independently drift from each other either — a stale
321
+ # Order_lifecycle.mmd fails THAT spec long before this one runs.
322
+ def diagram_showcase(root)
323
+ lifecycle = File.read(File.join(root, "docs/generated/diagrams/pizzas/Order_lifecycle.mmd")).strip
324
+ <<~MARKDOWN.strip
325
+ `bin/project_diagrams` reads a booted domain's own declaration and draws it as Mermaid — nine kinds so far: `<Name>_lifecycle.mmd`, `relationships.mmd`, `dispatch.mmd`, `roles.mmd`, `ports.mmd`, `read_models.mmd`, `<Name>_surface.mmd` (what a command does, and what it writes), `<Name>_saga.mmd`, and `frameworks.mmd`. Nothing hand-drawn — the same reason a domain is data at all. Order's own lifecycle, straight off the bluebook above:
326
+
327
+ ```mermaid
328
+ #{lifecycle}
329
+ ```
330
+
331
+ The full set for every domain in this checkout — `examples/pizzas`, `examples/banking` — lives in [`docs/generated/diagrams/`](docs/generated/diagrams/), held to the declaration by `spec/diagrams_spec.rb` the same drift-refusing way this page is held to its own source.
332
+ MARKDOWN
333
+ end
334
+
314
335
  def corpus_roster(root)
315
336
  dirs = Dir.glob(File.join(root, "examples/*/")).sort
316
337
  lines = dirs.filter_map do |dir|
@@ -33,10 +33,13 @@ module Hecks
33
33
 
34
34
  pairs.each_with_object({}) do |pair, args|
35
35
  path, value = split(pair)
36
- argument = options[path] || options[expand(path, options)] ||
37
- raise(Runtime::NotFound, unknown(path, options.keys))
36
+ # key? first, never `||` — full names whichever spelling (the
37
+ # bare path, or its one-argument expansion) actually declares
38
+ # this option, and the lookup below must hold to that same
39
+ # decision rather than re-guessing which one exists.
40
+ full = options.key?(path) ? path : expand(path, options)
41
+ argument = options.key?(full) ? options[full] : raise(Runtime::NotFound, unknown(path, options.keys))
38
42
 
39
- full = options.key?(path) ? path : expand(path, options)
40
43
  next append(args, full.split("."), cast(value, argument[:type])) if argument[:list]
41
44
 
42
45
  bury(args, full.split("."), cast(value, argument[:type]))
@@ -81,12 +81,24 @@ module Hecks
81
81
  end
82
82
 
83
83
  # A URL segment or a JSON body's "command" field, checked against what
84
- # the aggregate actually declares. `klass.commands` is the same
85
- # sorted, snake_cased list `AggregateDoor` built for its own door, so
86
- # a name this accepts is a name a `Handle` can actually dispatch.
84
+ # a `Handle` can actually dispatch — NOT `klass.commands`, which is
85
+ # `AggregateDoor`'s own door-level list and includes the one creating
86
+ # command too (`aggregate_door.rb`'s `commands` singleton method maps
87
+ # every `ir.commands`, full stop). A `Handle` only ever defines
88
+ # singleton methods for the NON-creating ones
89
+ # (`Handle#define_verb_methods`, `@ir.commands.reject(&:creates?)`) —
90
+ # the creating command lives on the aggregate class itself, dispatched
91
+ # through `.creating_command` above, not through a `Handle` in hand.
92
+ # Accepting a creating-command name here let it past this gate clean,
93
+ # only to blow up as a raw `NoMethodError` the moment a caller tried
94
+ # `handle.public_send(name, **args)`, instead of the 404 this method
95
+ # promises. Filtering `reject(&:creates?)` here, the same filter
96
+ # `Handle` itself applies, is what keeps "accepted here" and
97
+ # "dispatchable there" the same set.
87
98
  def validate_command!(klass, name)
88
99
  wanted = name.to_s
89
- return wanted if klass.commands.include?(wanted)
100
+ dispatchable = klass.ir.commands.reject(&:creates?).map { |command| "#{Naming.snake(command.hecks_name)}!" }
101
+ return wanted if dispatchable.include?(wanted)
90
102
 
91
103
  raise Runtime::NotFound, "#{klass.ir.hecks_name} declares no command named #{wanted.inspect}"
92
104
  end