hecks 1.2.0 → 1.4.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 (358) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +7 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +10 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +8 -8
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +31 -13
  7. data/lib/hecks/adapters/driven/heki/journal.rb +60 -2
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +5 -5
  9. data/lib/hecks/adapters/driven/heki.rb +13 -7
  10. data/lib/hecks/adapters/driven/identity_registry.rb +2 -2
  11. data/lib/hecks/adapters/driven/in_memory_ordering.rb +3 -3
  12. data/lib/hecks/adapters/driven/lambda/client.rb +34 -9
  13. data/lib/hecks/adapters/driven/lambda.rb +39 -33
  14. data/lib/hecks/adapters/driven/local_storage.rb +17 -10
  15. data/lib/hecks/adapters/driven/memory.rb +205 -9
  16. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +1 -1
  17. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  18. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  19. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  20. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  21. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  22. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  23. data/lib/hecks/adapters/driven/prism.rb +4 -4
  24. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  25. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  26. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  27. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  28. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  29. data/lib/hecks/adapters/driven.rb +4 -4
  30. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  31. data/lib/hecks/behaviors/dsl.rb +2 -2
  32. data/lib/hecks/behaviors/expectations.rb +51 -23
  33. data/lib/hecks/behaviors/ir.rb +1 -1
  34. data/lib/hecks/behaviors/rspec.rb +1 -1
  35. data/lib/hecks/behaviors/runner.rb +2 -2
  36. data/lib/hecks/behaviors.rb +1 -1
  37. data/lib/hecks/bluebook/aggregate.rb +13 -13
  38. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
  39. data/lib/hecks/bluebook/assembly/build.rb +1 -1
  40. data/lib/hecks/bluebook/assembly/contract.rb +39 -16
  41. data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
  42. data/lib/hecks/bluebook/assembly/marks.rb +18 -18
  43. data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
  44. data/lib/hecks/bluebook/assembly.rb +9 -9
  45. data/lib/hecks/bluebook/attribute.rb +9 -9
  46. data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
  47. data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
  48. data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
  49. data/lib/hecks/bluebook/behaviour/command.rb +23 -23
  50. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -3
  51. data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
  52. data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
  53. data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
  54. data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
  55. data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
  56. data/lib/hecks/bluebook/behaviour/query.rb +1 -1
  57. data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
  58. data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
  59. data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
  60. data/lib/hecks/bluebook/capabilities.rb +27 -0
  61. data/lib/hecks/bluebook/chapter.rb +28 -9
  62. data/lib/hecks/bluebook/command.rb +12 -12
  63. data/lib/hecks/bluebook/domain_port.rb +9 -9
  64. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  65. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  66. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
  67. data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
  68. data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
  69. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
  70. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
  71. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  72. data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
  73. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  74. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  75. data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
  76. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  77. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
  78. data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
  79. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  80. data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
  81. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  82. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  83. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
  84. data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
  85. data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
  86. data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
  87. data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
  88. data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
  89. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  90. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  91. data/lib/hecks/bluebook/entity.rb +11 -11
  92. data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
  93. data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
  94. data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
  95. data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
  96. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
  97. data/lib/hecks/bluebook/expression/resolver.rb +60 -62
  98. data/lib/hecks/bluebook/hexagon.rb +1 -1
  99. data/lib/hecks/bluebook/lifecycle.rb +1 -1
  100. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
  101. data/lib/hecks/bluebook/meta_validator/judge.rb +122 -86
  102. data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
  103. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
  104. data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
  105. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
  106. data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
  107. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
  108. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
  109. data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
  110. data/lib/hecks/bluebook/meta_validator.rb +70 -70
  111. data/lib/hecks/bluebook/model_check.rb +301 -84
  112. data/lib/hecks/bluebook/pattern_subset.rb +9 -9
  113. data/lib/hecks/bluebook/policy.rb +15 -13
  114. data/lib/hecks/bluebook/process_manager.rb +14 -14
  115. data/lib/hecks/bluebook/project_register.rb +6 -6
  116. data/lib/hecks/bluebook/query.rb +4 -4
  117. data/lib/hecks/bluebook/read_model.rb +14 -14
  118. data/lib/hecks/bluebook/reference.rb +8 -8
  119. data/lib/hecks/bluebook/smoke_test.rb +19 -19
  120. data/lib/hecks/bluebook/synthesizer.rb +12 -12
  121. data/lib/hecks/bluebook/translation.rb +4 -4
  122. data/lib/hecks/bluebook/value_object.rb +6 -6
  123. data/lib/hecks/bluebook.rb +2 -2
  124. data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
  125. data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
  126. data/lib/hecks/codemod.rb +36 -35
  127. data/lib/hecks/construct.rb +6 -6
  128. data/lib/hecks/corpus.rb +317 -0
  129. data/lib/hecks/deprecation.rb +95 -0
  130. data/lib/hecks/doc/reference.rb +19 -19
  131. data/lib/hecks/embryonaut_bluebook.rb +11 -11
  132. data/lib/hecks/facade/cli_door.rb +69 -10
  133. data/lib/hecks/facade/cli_runner.rb +105 -24
  134. data/lib/hecks/facade/command_request.rb +23 -0
  135. data/lib/hecks/facade/handle.rb +79 -32
  136. data/lib/hecks/facade/json_door.rb +106 -25
  137. data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
  138. data/lib/hecks/facade/surface/chapter.rb +26 -17
  139. data/lib/hecks/facade/surface.rb +16 -3
  140. data/lib/hecks/facade.rb +15 -4
  141. data/lib/hecks/forms/app.rb +46 -30
  142. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  143. data/lib/hecks/forms/field_renderer.rb +142 -6
  144. data/lib/hecks/forms/field_shape.rb +183 -20
  145. data/lib/hecks/forms/html.rb +51 -7
  146. data/lib/hecks/forms/index_renderer.rb +14 -2
  147. data/lib/hecks/forms/params.rb +120 -23
  148. data/lib/hecks/forms/port_argument.rb +2 -2
  149. data/lib/hecks/forms/query_form_renderer.rb +2 -2
  150. data/lib/hecks/forms/record_renderer.rb +2 -2
  151. data/lib/hecks/forms/record_table.rb +1 -1
  152. data/lib/hecks/forms/value_object_shape.rb +3 -3
  153. data/lib/hecks/forms.rb +24 -4
  154. data/lib/hecks/fqn.rb +1 -1
  155. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  156. data/lib/hecks/framework.rb +48 -17
  157. data/lib/hecks/freezer.rb +11 -11
  158. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
  159. data/lib/hecks/fuzzing/combination_miner.rb +119 -0
  160. data/lib/hecks/fuzzing/concurrent_dispatch.rb +361 -0
  161. data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
  162. data/lib/hecks/fuzzing/differential.rb +158 -0
  163. data/lib/hecks/fuzzing/domain_generator.rb +694 -0
  164. data/lib/hecks/fuzzing/era_boundary.rb +124 -0
  165. data/lib/hecks/fuzzing/form_census.rb +199 -0
  166. data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
  167. data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
  168. data/lib/hecks/fuzzing/isolated_boot.rb +226 -38
  169. data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
  170. data/lib/hecks/fuzzing/persistence_parity.rb +161 -0
  171. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  172. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +321 -41
  173. data/lib/hecks/fuzzing/properties/guards.rb +129 -26
  174. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
  175. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +31 -11
  176. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  177. data/lib/hecks/fuzzing/properties/querying.rb +17 -17
  178. data/lib/hecks/fuzzing/properties.rb +84 -39
  179. data/lib/hecks/fuzzing/qa_settings.rb +152 -0
  180. data/lib/hecks/fuzzing/replay.rb +224 -114
  181. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  182. data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
  183. data/lib/hecks/fuzzing/self_consistency.rb +676 -0
  184. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  185. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +101 -30
  186. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +55 -8
  187. data/lib/hecks/fuzzing/sequence_generator/picker.rb +31 -11
  188. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +123 -32
  189. data/lib/hecks/fuzzing/sequence_generator.rb +152 -31
  190. data/lib/hecks/fuzzing/shrinker.rb +197 -0
  191. data/lib/hecks/fuzzing/structural_skips.rb +39 -0
  192. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  193. data/lib/hecks/fuzzing/target_capabilities.rb +180 -0
  194. data/lib/hecks/fuzzing/value_generator.rb +66 -14
  195. data/lib/hecks/fuzzing.rb +11 -0
  196. data/lib/hecks/grammar/evolve.rb +10 -10
  197. data/lib/hecks/grammar.rb +7 -7
  198. data/lib/hecks/ir.rb +13 -13
  199. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  200. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  201. data/lib/hecks/language/bluebook/vocabulary.bluebook +365 -15
  202. data/lib/hecks/language/oidc.json +5 -0
  203. data/lib/hecks/literal.rb +9 -9
  204. data/lib/hecks/naming.rb +89 -21
  205. data/lib/hecks/ports/access_control.rb +58 -2
  206. data/lib/hecks/ports/agent/answers.rb +83 -6
  207. data/lib/hecks/ports/agent.rb +119 -35
  208. data/lib/hecks/ports/authentication.rb +44 -4
  209. data/lib/hecks/ports/authorization.rb +53 -11
  210. data/lib/hecks/ports/clock.rb +42 -23
  211. data/lib/hecks/ports/extraction.rb +16 -0
  212. data/lib/hecks/ports/identity_assignment.rb +24 -2
  213. data/lib/hecks/ports/identity_generation.rb +17 -3
  214. data/lib/hecks/ports/identity_resolution.rb +18 -1
  215. data/lib/hecks/ports/loading.rb +4 -0
  216. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  217. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  218. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  219. data/lib/hecks/ports/persistence/execution.rb +4 -0
  220. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  221. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  222. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  223. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  224. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  225. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  226. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  227. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  228. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  229. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  230. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  231. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +160 -58
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +44 -6
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +338 -83
  242. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  243. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  244. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  245. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
  246. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  247. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  248. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  249. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
  250. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
  251. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
  252. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  253. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  254. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  255. data/lib/hecks/ports/persistence/repository_factory.rb +29 -6
  256. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  257. data/lib/hecks/ports/persistence.rb +36 -1
  258. data/lib/hecks/ports/projection.rb +61 -7
  259. data/lib/hecks/ports/query/in_memory.rb +3 -3
  260. data/lib/hecks/ports/query/ordering.rb +6 -6
  261. data/lib/hecks/ports/query.rb +35 -0
  262. data/lib/hecks/projections/bootstrap_table.rb +112 -0
  263. data/lib/hecks/projections/diagrams.rb +75 -75
  264. data/lib/hecks/projections/glossary/html.rb +250 -0
  265. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  266. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  267. data/lib/hecks/projections/glossary/page.css +271 -0
  268. data/lib/hecks/projections/glossary/page.js +72 -0
  269. data/lib/hecks/projections/glossary/sections.rb +17 -0
  270. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  271. data/lib/hecks/projections/glossary.rb +214 -286
  272. data/lib/hecks/projections/ir.rb +1 -1
  273. data/lib/hecks/projections/model/deviations.rb +18 -17
  274. data/lib/hecks/projections/model.rb +25 -21
  275. data/lib/hecks/projections/oidc.rb +7 -7
  276. data/lib/hecks/projections/parser_table.rb +5 -5
  277. data/lib/hecks/projections/reference.rb +3 -3
  278. data/lib/hecks/projections/rust_vocabulary.rb +443 -0
  279. data/lib/hecks/projections/shape.rb +2 -2
  280. data/lib/hecks/projections/statements.rb +11 -11
  281. data/lib/hecks/projections/vocabulary.rb +9 -9
  282. data/lib/hecks/projections.rb +5 -3
  283. data/lib/hecks/projector/cli_projector.rb +29 -29
  284. data/lib/hecks/projector/docs_projector.rb +13 -13
  285. data/lib/hecks/projector/exporter.rb +42 -21
  286. data/lib/hecks/projector/ir_projector.rb +1 -1
  287. data/lib/hecks/projector/narrate_projector.rb +15 -22
  288. data/lib/hecks/projector/target.rb +13 -13
  289. data/lib/hecks/projector.rb +15 -15
  290. data/lib/hecks/query_ir.rb +47 -47
  291. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  292. data/lib/hecks/query_specification/common/comparison.rb +132 -24
  293. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  294. data/lib/hecks/query_specification/common/null_policy.rb +57 -13
  295. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  296. data/lib/hecks/query_specification/common/options.rb +25 -0
  297. data/lib/hecks/query_specification/field_path.rb +69 -15
  298. data/lib/hecks/query_specification/hop_path.rb +57 -20
  299. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  300. data/lib/hecks/rendering.rb +3 -3
  301. data/lib/hecks/router/namespace_installer.rb +3 -3
  302. data/lib/hecks/router.rb +1 -1
  303. data/lib/hecks/runtime/aggregate_lock.rb +11 -11
  304. data/lib/hecks/runtime/boot_gates.rb +3 -3
  305. data/lib/hecks/runtime/caller.rb +8 -8
  306. data/lib/hecks/runtime/capability_graph.rb +2 -2
  307. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
  308. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +64 -67
  309. data/lib/hecks/runtime/command_interpreter.rb +159 -102
  310. data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
  311. data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
  312. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  313. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  314. data/lib/hecks/runtime/command_rules/references.rb +94 -19
  315. data/lib/hecks/runtime/command_rules.rb +3 -0
  316. data/lib/hecks/runtime/dependency_planning.rb +11 -11
  317. data/lib/hecks/runtime/dispatcher.rb +283 -90
  318. data/lib/hecks/runtime/entity_element.rb +282 -46
  319. data/lib/hecks/runtime/entity_interpreter.rb +160 -69
  320. data/lib/hecks/runtime/errors.rb +19 -19
  321. data/lib/hecks/runtime/event.rb +6 -6
  322. data/lib/hecks/runtime/identity.rb +22 -22
  323. data/lib/hecks/runtime/instance.rb +39 -14
  324. data/lib/hecks/runtime/interpreting.rb +12 -12
  325. data/lib/hecks/runtime/invocation.rb +276 -0
  326. data/lib/hecks/runtime/loader.rb +14 -14
  327. data/lib/hecks/runtime/outbox.rb +23 -23
  328. data/lib/hecks/runtime/policy_interpreter.rb +54 -54
  329. data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
  330. data/lib/hecks/runtime/query_interpreter.rb +111 -56
  331. data/lib/hecks/runtime/reaction_invocation.rb +76 -9
  332. data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
  333. data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
  334. data/lib/hecks/runtime/reference_hop.rb +6 -6
  335. data/lib/hecks/runtime/refusal_wording.rb +92 -112
  336. data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
  337. data/lib/hecks/runtime/registry/verification.rb +36 -26
  338. data/lib/hecks/runtime/registry.rb +56 -27
  339. data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
  340. data/lib/hecks/runtime/routing.rb +10 -88
  341. data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
  342. data/lib/hecks/runtime/saga_interpreter.rb +93 -56
  343. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  344. data/lib/hecks/runtime/tenant_check.rb +9 -9
  345. data/lib/hecks/runtime/tenant_scope.rb +5 -5
  346. data/lib/hecks/runtime/value/admission.rb +75 -30
  347. data/lib/hecks/runtime/value/coercion.rb +379 -226
  348. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  349. data/lib/hecks/runtime/value.rb +28 -23
  350. data/lib/hecks/runtime.rb +7 -7
  351. data/lib/hecks/storehouse.rb +64 -64
  352. data/lib/hecks/version.rb +3 -3
  353. data/lib/hecks/vocabulary.rb +207 -5
  354. data/lib/hecks.rb +13 -11
  355. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
  356. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
  357. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
  358. metadata +43 -2
@@ -0,0 +1,694 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require_relative "form_census"
4
+
5
+ module Hecks
6
+ module Fuzzing
7
+ # A small, valid bluebook, written from a seed — so the QA loop can test
8
+ # construct combinations nobody has hand-authored yet.
9
+ #
10
+ # The ledger's most productive moments were a new stress domain's first
11
+ # sweep: `corrections` found four bugs, `referral_chain` four,
12
+ # `tenant_ledger` two. Every one of those domains was written by hand,
13
+ # one angle at a time, and `bin/qa_domain_novelty` exists precisely
14
+ # because the bugs live where two declared forms meet on one aggregate
15
+ # for the first time (`spec/combination_coverage_spec.rb`'s header).
16
+ # This writes those meetings mechanically: a seed picks two
17
+ # `FormCensus::FORMS` to force onto one aggregate, adds whatever other
18
+ # aggregates those forms need (a reference target, a two-hop chain),
19
+ # sprinkles extras (a lifecycle, a closed set, an entity, a query, a
20
+ # policy, a role), and renders the whole thing as ordinary bluebook
21
+ # source any runtime boots.
22
+ #
23
+ # A blueprint, not source, is the unit. `generate` answers a plain,
24
+ # JSON-shaped Hash (string keys) — a small IR of its own — and `render`
25
+ # turns it into source. That split is what makes a finding shrinkable
26
+ # at the domain level: `shrink_candidates` removes one element at a time
27
+ # (a query, a command, an entity, an aggregate, a given…) and `prune`
28
+ # drops whatever that removal left dangling, using the explicit
29
+ # `requires` tokens every dependent element carries. Nothing here
30
+ # parses Ruby back.
31
+ #
32
+ # Every generated domain is the same domain name — `QaGenerated`, in a
33
+ # `qa_generated/bluebook/qa_generated.bluebook` directory — because the
34
+ # directory basename doubles as a Rust module and Cargo feature name
35
+ # (`bin/project_rust`'s own landmine guard), and one fixed feature is
36
+ # what lets `bin/qa_generated_domains --rust` rebuild incrementally.
37
+ # Each domain is checked in its own child process for the same reason:
38
+ # two different shapes under one constant name never share a process.
39
+ module DomainGenerator
40
+ DOMAIN_NAME = "QaGenerated".freeze
41
+ DIRECTORY = "qa_generated".freeze
42
+ # `FORMS` — what this generator can build, defined at the bottom of
43
+ # this module, beside the `Builder::FORM_STEPS` table it reads.
44
+
45
+ # **No Rust-reserved snake names**. `Crate` was here first and every
46
+ # domain holding it failed to compile under `--rust`: its snake form
47
+ # is the keyword `crate`, which Rust cannot escape even as a raw
48
+ # identifier, and the projection emits it as a module and field name
49
+ # unguarded (`bin/project_rust` only guards the domain name). That is
50
+ # a real finding, reported rather than generated into every run.
51
+ AGGREGATE_NAMES = %w[Ticket Desk Parcel Venue Kiosk Hangar].freeze
52
+ ENTITY_NAMES = %w[Line Stamp].freeze
53
+ CLOSED_SETS = [%w[low high], %w[red amber green], %w[draft final]].freeze
54
+ ROLES = %w[Clerk Manager].freeze
55
+
56
+ # Forms that need another aggregate to reference, and the ones that
57
+ # need a two-hop chain ending in a lifecycle.
58
+ REFERENCE_FORMS = %w[reference_attr revalued_reference].freeze
59
+ CHAIN_FORMS = %w[two_hop_given multi_hop_where].freeze
60
+
61
+ module_function
62
+
63
+ def generate(seed:, forms: nil)
64
+ random = Random.new(seed)
65
+ forms = Array(forms || FORMS.sample(2, random: random)).map(&:to_s)
66
+ unknown = forms - FORMS
67
+ raise ArgumentError, "unknown form(s) #{unknown.join(', ')} — FormCensus::FORMS names #{FORMS.join(', ')}" if unknown.any?
68
+
69
+ blueprint = Builder.new(random, forms).build
70
+ prune(blueprint.merge("seed" => seed, "forms" => forms))
71
+ end
72
+
73
+ # `<root>/qa_generated/bluebook/qa_generated.bluebook`, plus the
74
+ # blueprint beside the domain directory (never inside it — a stray
75
+ # file under `bluebook/` would be loaded as a chapter).
76
+ #
77
+ # A blueprint carrying `"source"` is bluebook text someone else wrote
78
+ # (`bin/qa_mine_combinations`' agent): it is adopted, not rendered,
79
+ # and its empty `aggregates`/`policies` leave nothing to shrink.
80
+ def write(blueprint, root)
81
+ domain = File.join(root, DIRECTORY)
82
+ FileUtils.rm_rf(domain)
83
+ FileUtils.mkdir_p(File.join(domain, "bluebook"))
84
+ text = blueprint["source"] ? adopt(blueprint["source"]) : render(blueprint)
85
+ File.write(File.join(domain, "bluebook", "#{DIRECTORY}.bluebook"), text)
86
+ File.write(File.join(root, "blueprint.json"), JSON.pretty_generate(blueprint))
87
+ domain
88
+ end
89
+
90
+ # Renames an outside bluebook's own chapter to `QaGenerated`, the one
91
+ # name the scratch crate and child processes are built around.
92
+ def adopt(source)
93
+ source.sub(/Hecks\.bluebook\s*\(?\s*(["'])[^"']+\1/) { "Hecks.bluebook #{DOMAIN_NAME.inspect}" }
94
+ end
95
+
96
+ # ── rendering ───────────────────────────────────────────────────
97
+
98
+ def render(blueprint)
99
+ out = []
100
+ out << "# GENERATED by Hecks::Fuzzing::DomainGenerator — seed #{blueprint['seed']}, forms " \
101
+ "#{blueprint['forms'].join(', ')}. Not hand-written; see bin/qa_generated_domains."
102
+ out << "Hecks.bluebook #{DOMAIN_NAME.inspect} do"
103
+ out << " vision #{"A generated domain that puts #{blueprint['forms'].join(' and ')} together.".inspect}"
104
+ out << ""
105
+ out << " core"
106
+ blueprint["aggregates"].each { |aggregate| out.concat(render_aggregate(aggregate)) }
107
+ blueprint["policies"].each { |policy| out.concat(render_policy(policy)) }
108
+ out << "end"
109
+ "#{out.join("\n")}\n"
110
+ end
111
+
112
+ def render_aggregate(aggregate)
113
+ out = aggregate_header(aggregate)
114
+ aggregate["attributes"].each { |attribute| out << " #{render_attribute(attribute)}" }
115
+ aggregate["references"].each { |target| out << " reference_to #{target}" }
116
+ aggregate["vos"].each { |name, vo| out.concat(render_value_object(name, vo, " ")) }
117
+ out.concat(render_lifecycle(aggregate["lifecycle"], " ")) if aggregate["lifecycle"]
118
+ aggregate["invariants"].each { |inv| out << " invariant(#{inv['label'].inspect}) { #{inv['expr']} }" }
119
+ aggregate["entities"].each { |entity| out.concat(render_entity(entity)) }
120
+ aggregate["commands"].each { |command| out.concat(render_command(command, aggregate["name"], " ")) }
121
+ aggregate["queries"].each { |query| out.concat(render_query(query)) }
122
+ out << " end"
123
+ end
124
+
125
+ def aggregate_header(aggregate)
126
+ ["", " aggregate #{aggregate['name'].inspect} do",
127
+ " description #{"A generated #{aggregate['name'].downcase}.".inspect}", "",
128
+ " identified_by #{aggregate['identity'].map { |part| ":#{part}" }.join(', ')}"]
129
+ end
130
+
131
+ def render_entity(entity)
132
+ out = ["", " entity #{entity['name'].inspect} do",
133
+ " description #{"A generated #{entity['name'].downcase}.".inspect}"]
134
+ out << " identified_by #{entity['identity'].map { |part| ":#{part}" }.join(', ')}"
135
+ entity["attributes"].each { |attribute| out << " #{render_attribute(attribute)}" }
136
+ out.concat(render_lifecycle(entity["lifecycle"], " ")) if entity["lifecycle"]
137
+ entity["commands"].each { |command| out.concat(render_command(command, nil, " ")) }
138
+ out << " end"
139
+ end
140
+
141
+ def render_attribute(attribute)
142
+ type = attribute["list"] ? "list_of(#{attribute['type']})" : attribute["type"]
143
+ line = "attribute :#{attribute['name']}, #{type}"
144
+ line += ", optional: true" if attribute["optional"]
145
+ line += ", default: { value: #{attribute['default'].inspect} }" if attribute.key?("default")
146
+ line
147
+ end
148
+
149
+ def render_value_object(name, value_object, indent)
150
+ body =
151
+ case value_object["kind"]
152
+ when "string"
153
+ ["attribute :value, String, pattern: '[^ \\t\\n\\r]'",
154
+ "invariant(#{"a #{name} is not blank".inspect}) { !value.to_s.empty? }"]
155
+ when "positive"
156
+ ["attribute :value, Integer", "invariant(#{"a #{name} is positive".inspect}) { value.positive? }"]
157
+ when "integer"
158
+ ["attribute :value, Integer"]
159
+ when "closed"
160
+ ["attribute :value, String, one_of: #{value_object['members'].inspect}"]
161
+ end
162
+ ["", "#{indent}value_object #{name.inspect} do", *body.map { |line| "#{indent} #{line}" }, "#{indent}end"]
163
+ end
164
+
165
+ def render_lifecycle(lifecycle, indent)
166
+ out = ["", "#{indent}lifecycle :#{lifecycle['field']}, default: #{lifecycle['default'].inspect} do"]
167
+ lifecycle["transitions"].each do |transition|
168
+ from = transition["from"].size == 1 ? transition["from"].first.inspect : transition["from"].inspect
169
+ out << "#{indent} transition #{transition['command'].inspect} => #{transition['to'].inspect}, from: #{from}"
170
+ end
171
+ out << "#{indent}end"
172
+ end
173
+
174
+ def render_command(command, self_name, indent)
175
+ out = ["", "#{indent}command #{command['name'].inspect} do"]
176
+ out << "#{indent} role #{command['role'].inspect}" if command["role"]
177
+ out << "#{indent} goal #{"#{command['name']} it".inspect}"
178
+ out << "#{indent} reference_to #{self_name}" if self_name && !command["creates"]
179
+ command["references"].each { |target| out << "#{indent} reference_to #{target}" }
180
+ command["args"].each { |arg| out << "#{indent} #{render_attribute(arg)}" }
181
+ command["givens"].each { |given| out << "#{indent} given(#{given['label'].inspect}) { #{given['expr']} }" }
182
+ command["sets"].each { |set| out << "#{indent} #{render_set(set)}" }
183
+ command["emits"].each { |event| out << "#{indent} emits #{event.inspect}" }
184
+ out << "#{indent}end"
185
+ end
186
+
187
+ def render_set(set)
188
+ return "sets :#{set['target']}, to: :#{set['to']}" if set["to"]
189
+ return "sets :#{set['target']}, append: { #{set['append'].map { |k, v| "#{k}: :#{v}" }.join(', ')} }" if set["append"]
190
+
191
+ "sets :#{set['target']}"
192
+ end
193
+
194
+ def render_query(query)
195
+ out = ["", " query #{query['name'].inspect} do", " description #{"Generated #{query['name']}.".inspect}"]
196
+ query["wheres"].each do |where|
197
+ out << " where(:#{where['field'].inspect} => #{where['value'].inspect})"
198
+ end
199
+ out << " order_by :#{query['order_by']}"
200
+ out << " end"
201
+ end
202
+
203
+ def render_policy(policy)
204
+ ["", " policy #{policy['name'].inspect} do", " on #{policy['on']}", " trigger #{policy['trigger']}", " end"]
205
+ end
206
+
207
+ # ── domain-level shrinking ──────────────────────────────────────
208
+
209
+ # Every blueprint one removal smaller, each already pruned. Identity
210
+ # attributes and creating commands are never offered: without them
211
+ # there is no domain left to dispatch against.
212
+ def shrink_candidates(blueprint)
213
+ removals(blueprint).map { |path| prune(remove_at(blueprint, path)) }.uniq.reject { |candidate| candidate == blueprint }
214
+ end
215
+
216
+ def removals(blueprint)
217
+ paths = blueprint["policies"].each_index.map { |i| ["policies", i] }
218
+ blueprint["aggregates"].each_with_index do |aggregate, a|
219
+ at = ["aggregates", a]
220
+ paths << at if a.positive?
221
+ %w[queries invariants entities references].each { |key| aggregate[key].each_index { |i| paths << [*at, key, i] } }
222
+ paths << [*at, "lifecycle"] if aggregate["lifecycle"]
223
+ aggregate["attributes"].each_with_index do |attribute, i|
224
+ paths << [*at, "attributes", i] unless aggregate["identity"].include?(attribute["name"])
225
+ end
226
+ aggregate["commands"].each_with_index { |command, i| paths.concat(command_removals(command, [*at, "commands", i])) }
227
+ paths.concat(entity_removals(aggregate, at))
228
+ end
229
+ paths
230
+ end
231
+
232
+ def entity_removals(aggregate, at)
233
+ aggregate["entities"].each_with_index.flat_map do |entity, e|
234
+ paths = entity["lifecycle"] ? [[*at, "entities", e, "lifecycle"]] : []
235
+ paths + entity["commands"].each_index.map { |i| [*at, "entities", e, "commands", i] }
236
+ end
237
+ end
238
+
239
+ def command_removals(command, at)
240
+ paths = command["creates"] ? [] : [at]
241
+ command["givens"].each_index { |g| paths << [*at, "givens", g] }
242
+ paths << [*at, "role"] if command["role"]
243
+ paths << [*at, "emits", command["emits"].size - 1] if command["emits"].size > 1
244
+ paths
245
+ end
246
+
247
+ def remove_at(blueprint, path)
248
+ copy = JSON.parse(JSON.generate(blueprint))
249
+ *parents, last = path
250
+ holder = parents.empty? ? copy : copy.dig(*parents)
251
+ holder.is_a?(Array) ? holder.delete_at(last) : holder.delete(last)
252
+ copy
253
+ end
254
+
255
+ # Drop whatever a removal left dangling, to a fixpoint. Every element
256
+ # that depends on another carries `requires` — tokens naming exactly
257
+ # what must still exist (`attribute:Ticket.score`, `lifecycle:Desk`,
258
+ # `reference:Ticket->Desk`, `command:Ticket.Close`, …) — so this is
259
+ # set arithmetic over those tokens, never a reading of the source.
260
+ def prune(blueprint)
261
+ current = JSON.parse(JSON.generate(blueprint))
262
+ loop do
263
+ available = tokens(current)
264
+ before = JSON.generate(current)
265
+ prune_once!(current, available)
266
+ break if JSON.generate(current) == before
267
+ end
268
+ current
269
+ end
270
+
271
+ def prune_once!(blueprint, available)
272
+ keep = ->(item) { Array(item["requires"]).all? { |token| available.include?(token) } }
273
+ blueprint["policies"].select!(&keep)
274
+ blueprint["aggregates"].each { |aggregate| prune_aggregate!(aggregate, available, keep) }
275
+ end
276
+
277
+ def prune_aggregate!(aggregate, available, keep)
278
+ aggregate["references"].select! { |target| available.include?("aggregate:#{target}") }
279
+ %w[attributes invariants entities].each { |key| aggregate[key].select!(&keep) }
280
+ aggregate["queries"].each { |query| query["wheres"].select!(&keep) }
281
+ aggregate["queries"].select! { |query| query["wheres"].any? }
282
+ [aggregate, *aggregate["entities"]].each do |owner|
283
+ prune_lifecycle!(owner, available)
284
+ owner["commands"].each { |command| prune_command!(command, available) }
285
+ end
286
+ end
287
+
288
+ def prune_lifecycle!(owner, available)
289
+ return unless owner["lifecycle"]
290
+
291
+ transitions = owner["lifecycle"]["transitions"]
292
+ transitions.select! { |t| available.include?(t["requires"].first) }
293
+ reachable = reachable_states(owner["lifecycle"]["default"], transitions)
294
+ transitions.select! { |t| t["from"].any? { |state| reachable.include?(state) } }
295
+ owner["lifecycle"] = nil if transitions.empty?
296
+ end
297
+
298
+ # Every state a path from the default reaches. A transition out of a
299
+ # state nothing reaches can never fire, so a removal that orphans one
300
+ # goes too: shrinking away `Close` used to leave `Reopen from closed`
301
+ # behind — qa/stress_domains/generated_revalued_shape was promoted
302
+ # that way, and bin/model_check reports it as a dead transition.
303
+ def reachable_states(default, transitions)
304
+ reachable = [default]
305
+ loop do
306
+ reached = transitions.select { |t| t["from"].any? { |state| reachable.include?(state) } }.map { |t| t["to"] }
307
+ return reachable if (reached - reachable).empty?
308
+
309
+ reachable |= reached
310
+ end
311
+ end
312
+
313
+ def prune_command!(command, available)
314
+ keep = ->(item) { Array(item["requires"]).all? { |token| available.include?(token) } }
315
+ command["references"].select! { |target| available.include?("aggregate:#{target}") }
316
+ %w[args givens sets].each { |key| command[key].select!(&keep) }
317
+ end
318
+
319
+ def tokens(blueprint)
320
+ blueprint["aggregates"].each_with_object(Set.new) do |aggregate, set|
321
+ name = aggregate["name"]
322
+ set << "aggregate:#{name}"
323
+ set << "lifecycle:#{name}" if aggregate["lifecycle"]
324
+ aggregate["attributes"].each { |attribute| set << "attribute:#{name}.#{attribute['name']}" }
325
+ aggregate["references"].each { |target| set << "reference:#{name}->#{target}" }
326
+ aggregate["commands"].each do |command|
327
+ set << "command:#{name}.#{command['name']}"
328
+ command["references"].each { |target| set << "command_reference:#{name}.#{command['name']}->#{target}" }
329
+ command["emits"].each { |event| set << "event:#{name}.#{event}" }
330
+ end
331
+ aggregate["entities"].each do |entity|
332
+ set << "entity:#{name}.#{entity['name']}"
333
+ set << "lifecycle:#{name}.#{entity['name']}" if entity["lifecycle"]
334
+ entity["commands"].each { |command| set << "command:#{name}.#{entity['name']}.#{command['name']}" }
335
+ end
336
+ end
337
+ end
338
+
339
+ def snake(name) = name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
340
+
341
+ # ── building ────────────────────────────────────────────────────
342
+
343
+ # One seeded pass: the aggregates the forms need, the forms forced
344
+ # onto the first ("primary") aggregate, then extras everywhere.
345
+ class Builder
346
+ def initialize(random, forms)
347
+ @random = random
348
+ @forms = forms
349
+ @names = AGGREGATE_NAMES.shuffle(random: random)
350
+ @policies = []
351
+ end
352
+
353
+ def build
354
+ chain = @forms.intersect?(CHAIN_FORMS)
355
+ count = if chain then 3
356
+ elsif @forms.intersect?(REFERENCE_FORMS) then 2
357
+ else 1
358
+ end
359
+ count += 1 if count < 3 && @random.rand < 0.3
360
+ aggregates = @names.first(count).map { |name| base(name) }
361
+ primary = aggregates.first
362
+
363
+ link_chain(aggregates.first(3)) if chain
364
+ @forms.each { |form| apply_form(form, primary, aggregates) }
365
+ aggregates.each { |aggregate| extras(aggregate, aggregates) }
366
+ { "aggregates" => aggregates, "policies" => @policies }
367
+ end
368
+
369
+ # One step per `FormCensus::FORMS` entry, each run against the
370
+ # builder with the primary aggregate and every aggregate in play.
371
+ FORM_STEPS = {
372
+ "composite_id" => ->(primary, _) { composite_id(primary) },
373
+ "has_entity" => ->(primary, _) { entity(primary) },
374
+ "two_entities" => ->(primary, _) { 2.times { entity(primary) } },
375
+ "composite_piece" => ->(primary, _) { entity(primary, composite: true) },
376
+ "multi_emit" => ->(primary, _) { creating(primary)["emits"] << "#{primary['name']}Logged" },
377
+ "lifecycle" => ->(primary, _) { lifecycle(primary) },
378
+ "piece_lifecycle" => ->(primary, _) { entity(primary, lifecycle: true) },
379
+ "has_query" => ->(primary, _) { query(primary) },
380
+ "list_attr" => ->(primary, _) { list_attr(primary) },
381
+ "reference_attr" => ->(primary, all) { reference_attr(primary, all[1]) },
382
+ "closed_set" => ->(primary, _) { closed_set(primary) },
383
+ "has_default" => ->(primary, _) { default_attr(primary) },
384
+ "has_optional" => ->(primary, _) { optional_arg(primary) },
385
+ "two_hop_given" => ->(primary, all) { two_hop_given(primary, all[1], all[2]) },
386
+ "multi_hop_where" => ->(primary, all) { multi_hop_where(primary, all[1], all[2]) },
387
+ "revalued_reference" => ->(primary, all) { revalued_reference(primary, all[1]) }
388
+ }.freeze
389
+
390
+ private
391
+
392
+ def chance?(probability) = @random.rand < probability
393
+
394
+ def base(name)
395
+ aggregate = { "name" => name, "identity" => ["code"], "vos" => {}, "attributes" => [], "references" => [],
396
+ "lifecycle" => nil, "invariants" => [], "entities" => [], "commands" => [], "queries" => [] }
397
+ vo(aggregate, "#{name}Code", "string")
398
+ aggregate["attributes"] << { "name" => "code", "type" => "#{name}Code" }
399
+ aggregate["commands"] << { "name" => "Open", "creates" => true, "references" => [],
400
+ "args" => [{ "name" => "code", "type" => "#{name}Code" }],
401
+ "givens" => [], "sets" => [{ "target" => "code" }], "emits" => ["#{name}Opened"] }
402
+ aggregate
403
+ end
404
+
405
+ def vo(aggregate, name, kind, members: nil)
406
+ aggregate["vos"][name] ||= { "kind" => kind }.merge(members ? { "members" => members } : {})
407
+ end
408
+
409
+ def creating(aggregate) = aggregate["commands"].find { |command| command["creates"] }
410
+
411
+ def command(aggregate, name, args: [], sets: [], givens: [], emits: nil)
412
+ existing = aggregate["commands"].find { |candidate| candidate["name"] == name }
413
+ return existing if existing
414
+
415
+ entry = { "name" => name, "creates" => false, "references" => [], "args" => args, "givens" => givens,
416
+ "sets" => sets, "emits" => emits || ["#{aggregate['name']}#{past(name)}"] }
417
+ aggregate["commands"] << entry
418
+ entry
419
+ end
420
+
421
+ def past(verb) = verb.end_with?("e") ? "#{verb}d" : "#{verb}ed"
422
+
423
+ # ── the forms ────────────────────────────────────────────────
424
+
425
+ def apply_form(form, primary, aggregates) = instance_exec(primary, aggregates, &FORM_STEPS.fetch(form))
426
+
427
+ def composite_id(aggregate)
428
+ return if aggregate["identity"].size > 1
429
+
430
+ name = aggregate["name"]
431
+ vo(aggregate, "#{name}Branch", "string")
432
+ vo(aggregate, "#{name}Number", "positive")
433
+ aggregate["identity"] = %w[branch number]
434
+ aggregate["attributes"].reject! { |attribute| attribute["name"] == "code" }
435
+ aggregate["attributes"].unshift({ "name" => "branch", "type" => "#{name}Branch" },
436
+ { "name" => "number", "type" => "#{name}Number" })
437
+ open = creating(aggregate)
438
+ open["args"] = [{ "name" => "branch", "type" => "#{name}Branch" }, { "name" => "number", "type" => "#{name}Number" }]
439
+ open["sets"] = [{ "target" => "branch" }, { "target" => "number" }]
440
+ aggregate["vos"].delete("#{name}Code")
441
+ end
442
+
443
+ def entity(aggregate, composite: false, lifecycle: false)
444
+ taken = aggregate["entities"].map { |entity| entity["name"] }
445
+ name = (ENTITY_NAMES - taken).first
446
+ return unless name
447
+
448
+ owner = aggregate["name"]
449
+ list = "#{name.downcase}s"
450
+ parts = composite ? %w[batch sequence] : ["sequence"]
451
+ vo(aggregate, "#{name}Sequence", "positive")
452
+ vo(aggregate, "#{name}Batch", "string") if composite
453
+ vo(aggregate, "#{name}Label", "string")
454
+ types = { "sequence" => "#{name}Sequence", "batch" => "#{name}Batch" }
455
+
456
+ aggregate["attributes"] << { "name" => list, "type" => name, "list" => true, "requires" => ["entity:#{owner}.#{name}"] }
457
+ # Event names are qualified by the owner aggregate, not just the
458
+ # entity type — `ENTITY_NAMES` is a small pool (`Line`, `Stamp`)
459
+ # shared across every aggregate in a domain, and `extras` can pick
460
+ # the same entity name on a different aggregate than a form forced
461
+ # it onto (composite there, plain here, or vice versa). Aggregate
462
+ # names are always unique within one generated domain, so this is
463
+ # the same qualification ordinary commands already get by default
464
+ # (`"#{aggregate['name']}#{past(name)}"`) — without it, two
465
+ # aggregates can both emit a bare "LineAdded" with different
466
+ # shapes, which `validate_event_shapes!` correctly refuses.
467
+ piece = { "name" => name, "identity" => parts, "requires" => [],
468
+ "attributes" => parts.map { |part| { "name" => part, "type" => types[part] } } +
469
+ [{ "name" => "label", "type" => "#{name}Label", "optional" => true }],
470
+ "lifecycle" => nil,
471
+ "commands" => [{ "name" => "Label", "creates" => false, "references" => [],
472
+ "args" => [{ "name" => "label", "type" => "#{name}Label" }], "givens" => [],
473
+ "sets" => [{ "target" => "label" }], "emits" => ["#{owner}#{name}Labeled"] }] }
474
+ if lifecycle || chance?(0.2)
475
+ piece["commands"] << { "name" => "Settle", "creates" => false, "references" => [], "args" => [], "givens" => [],
476
+ "sets" => [], "emits" => ["#{owner}#{name}Settled"] }
477
+ piece["lifecycle"] = { "field" => "state", "default" => "pending",
478
+ "transitions" => [{ "command" => "Settle", "to" => "settled", "from" => ["pending"],
479
+ "requires" => ["command:#{owner}.#{name}.Settle"] }] }
480
+ end
481
+ aggregate["entities"] << piece
482
+ command(aggregate, "Add#{name}",
483
+ args: parts.map { |part| { "name" => part, "type" => types[part] } },
484
+ sets: [{ "target" => list, "append" => parts.to_h { |part| [part, part] },
485
+ "requires" => ["attribute:#{owner}.#{list}", "entity:#{owner}.#{name}"] }],
486
+ emits: ["#{owner}#{name}Added"])
487
+ end
488
+
489
+ def lifecycle(aggregate)
490
+ return if aggregate["lifecycle"]
491
+
492
+ name = aggregate["name"]
493
+ command(aggregate, "Close")
494
+ command(aggregate, "Reopen")
495
+ aggregate["lifecycle"] = { "field" => "status", "default" => "open", "transitions" => [
496
+ { "command" => "Close", "to" => "closed", "from" => ["open"], "requires" => ["command:#{name}.Close"] },
497
+ { "command" => "Reopen", "to" => "open", "from" => ["closed"], "requires" => ["command:#{name}.Reopen"] }
498
+ ] }
499
+ end
500
+
501
+ def query(aggregate)
502
+ return if aggregate["queries"].any? { |query| query["name"] == "Listed" }
503
+
504
+ name = aggregate["name"]
505
+ where = if aggregate["lifecycle"] || chance?(0.5)
506
+ lifecycle(aggregate)
507
+ { "field" => "status", "value" => "open", "requires" => ["lifecycle:#{name}"] }
508
+ else
509
+ members = closed_set(aggregate)
510
+ { "field" => "priority.value", "value" => members.first, "requires" => ["attribute:#{name}.priority"] }
511
+ end
512
+ aggregate["queries"] << { "name" => "Listed", "wheres" => [where], "order_by" => aggregate["identity"].first }
513
+ end
514
+
515
+ def list_attr(aggregate)
516
+ name = aggregate["name"]
517
+ return if aggregate["attributes"].any? { |attribute| attribute["name"] == "tags" }
518
+
519
+ vo(aggregate, "#{name}Tag", "string")
520
+ aggregate["attributes"] << { "name" => "tags", "type" => "#{name}Tag", "list" => true }
521
+ command(aggregate, "Retag", args: [{ "name" => "tags", "type" => "#{name}Tag", "list" => true }],
522
+ sets: [{ "target" => "tags", "requires" => ["attribute:#{name}.tags"] }])
523
+ end
524
+
525
+ # Answers the closed set's members, so a query can filter on one.
526
+ def closed_set(aggregate)
527
+ name = aggregate["name"]
528
+ existing = aggregate["vos"]["#{name}Priority"]
529
+ return existing["members"] if existing
530
+
531
+ members = CLOSED_SETS.sample(random: @random)
532
+ vo(aggregate, "#{name}Priority", "closed", members: members)
533
+ aggregate["attributes"] << { "name" => "priority", "type" => "#{name}Priority", "optional" => true }
534
+ command(aggregate, "Prioritize", args: [{ "name" => "priority", "type" => "#{name}Priority" }],
535
+ sets: [{ "target" => "priority", "requires" => ["attribute:#{name}.priority"] }])
536
+ members
537
+ end
538
+
539
+ def default_attr(aggregate)
540
+ name = aggregate["name"]
541
+ return if aggregate["attributes"].any? { |attribute| attribute["name"] == "score" }
542
+
543
+ vo(aggregate, "#{name}Score", "integer")
544
+ aggregate["attributes"] << { "name" => "score", "type" => "#{name}Score", "default" => 0 }
545
+ givens = []
546
+ if chance?(0.5)
547
+ givens << { "label" => "a score never drops", "expr" => "amount.value >= score.value",
548
+ "requires" => ["attribute:#{name}.score"] }
549
+ end
550
+ command(aggregate, "Rescore", args: [{ "name" => "amount", "type" => "#{name}Score" }], givens: givens,
551
+ sets: [{ "target" => "score", "to" => "amount",
552
+ "requires" => ["attribute:#{name}.score"] }])
553
+ return unless chance?(0.5)
554
+
555
+ aggregate["invariants"] << { "label" => "a score is never negative", "expr" => "score.value >= 0",
556
+ "requires" => ["attribute:#{name}.score"] }
557
+ end
558
+
559
+ def optional_arg(aggregate)
560
+ name = aggregate["name"]
561
+ return if aggregate["attributes"].any? { |attribute| attribute["name"] == "note" }
562
+
563
+ vo(aggregate, "#{name}Note", "string")
564
+ aggregate["attributes"] << { "name" => "note", "type" => "#{name}Note", "optional" => true }
565
+ command(aggregate, "Annotate", args: [{ "name" => "note", "type" => "#{name}Note", "optional" => true }],
566
+ sets: [{ "target" => "note", "requires" => ["attribute:#{name}.note"] }])
567
+ end
568
+
569
+ # An aggregate-level `reference_to`, set by the owner's own creating
570
+ # command (`Member.Join`'s shape, qa/stress_domains/referral_chain).
571
+ def reference_attr(owner, target)
572
+ return unless target
573
+ return if owner["references"].include?(target["name"])
574
+
575
+ owner["references"] << target["name"]
576
+ open = creating(owner)
577
+ open["references"] << target["name"]
578
+ open["sets"] << { "target" => snake(target["name"]),
579
+ "requires" => ["reference:#{owner['name']}->#{target['name']}"] }
580
+ return unless target["lifecycle"] && chance?(0.5)
581
+
582
+ open["givens"] << { "label" => "the #{snake(target['name'])} is open",
583
+ "expr" => "#{snake(target['name'])}.status == \"open\"",
584
+ "requires" => ["lifecycle:#{target['name']}",
585
+ "command_reference:#{owner['name']}.Open->#{target['name']}"] }
586
+ end
587
+
588
+ # primary -> middle -> root, root with a lifecycle.
589
+ def link_chain(chain)
590
+ primary, middle, root = chain
591
+ lifecycle(root)
592
+ reference_attr(middle, root)
593
+ reference_attr(primary, middle)
594
+ end
595
+
596
+ def two_hop_given(primary, middle, root)
597
+ return unless middle && root
598
+
599
+ path = "#{snake(middle['name'])}.#{snake(root['name'])}.status"
600
+ creating(primary)["givens"] << {
601
+ "label" => "the #{snake(middle['name'])}'s #{snake(root['name'])} is open", "expr" => "#{path} == \"open\"",
602
+ "requires" => ["lifecycle:#{root['name']}", "reference:#{middle['name']}->#{root['name']}",
603
+ "command_reference:#{primary['name']}.Open->#{middle['name']}"]
604
+ }
605
+ end
606
+
607
+ def multi_hop_where(primary, middle, root)
608
+ return unless middle && root
609
+
610
+ field = "#{snake(middle['name'])}/#{snake(root['name'])}/status"
611
+ primary["queries"] << {
612
+ "name" => "ThroughOpen#{root['name']}", "order_by" => primary["identity"].first,
613
+ "wheres" => [{ "field" => field, "value" => "open",
614
+ "requires" => ["lifecycle:#{root['name']}", "reference:#{middle['name']}->#{root['name']}",
615
+ "reference:#{primary['name']}->#{middle['name']}"] }]
616
+ }
617
+ end
618
+
619
+ # A command redeclaring the aggregate's own reference field under a
620
+ # plain value object — `Referral.Reassign`'s shape (ADR 0037 F5).
621
+ def revalued_reference(owner, target)
622
+ return unless target
623
+
624
+ reference_attr(owner, target)
625
+ field = snake(target["name"])
626
+ vo(owner, "#{target['name']}Handle", "string")
627
+ requires = ["reference:#{owner['name']}->#{target['name']}"]
628
+ command(owner, "Repoint", args: [{ "name" => field, "type" => "#{target['name']}Handle" }],
629
+ sets: [{ "target" => field, "requires" => requires }])
630
+ end
631
+
632
+ # ── extras, on every aggregate ───────────────────────────────
633
+
634
+ def extras(aggregate, aggregates)
635
+ extra_shape(aggregate)
636
+ extra_wiring(aggregate, aggregates)
637
+ end
638
+
639
+ def extra_shape(aggregate)
640
+ lifecycle(aggregate) if chance?(0.4)
641
+ closed_set(aggregate) if chance?(0.25)
642
+ default_attr(aggregate) if chance?(0.25)
643
+ optional_arg(aggregate) if chance?(0.25)
644
+ entity(aggregate) if chance?(0.2)
645
+ query(aggregate) if chance?(0.3)
646
+ emits = creating(aggregate)["emits"]
647
+ emits << "#{aggregate['name']}Logged" if chance?(0.15) && emits.size == 1
648
+ end
649
+
650
+ def extra_wiring(aggregate, aggregates)
651
+ other = (aggregates - [aggregate]).sample(random: @random)
652
+ reference_attr(aggregate, other) if other && chance?(0.15) && !creates_cycle?(aggregate, other, aggregates)
653
+ aggregate["commands"].each { |command| command["role"] = ROLES.sample(random: @random) if chance?(0.3) }
654
+ policy(aggregate) if aggregate["lifecycle"] && chance?(0.25)
655
+ end
656
+
657
+ def creates_cycle?(from, to, aggregates)
658
+ seen = Set.new
659
+ stack = [to["name"]]
660
+ until stack.empty?
661
+ name = stack.pop
662
+ return true if name == from["name"]
663
+ next unless seen.add?(name)
664
+
665
+ stack.concat(aggregates.find { |aggregate| aggregate["name"] == name }["references"])
666
+ end
667
+ false
668
+ end
669
+
670
+ def policy(aggregate)
671
+ name = aggregate["name"]
672
+ event = creating(aggregate)["emits"].first
673
+ @policies << { "name" => "On#{event}Close", "on" => "#{name}::#{event}", "trigger" => "#{name}::Close",
674
+ "requires" => ["event:#{name}.#{event}", "command:#{name}.Close", "lifecycle:#{name}"] }
675
+ end
676
+
677
+ def snake(name) = DomainGenerator.snake(name)
678
+ end
679
+
680
+ # What this generator can build, not everything the census names.
681
+ # This read `FormCensus::FORMS.keys`, which quietly assumed the two
682
+ # tables would always agree — and they stopped agreeing the moment
683
+ # the census learned a form (`corrects`, `role_gated`) that
684
+ # `Builder::FORM_STEPS` has no recipe for: `generate` raised
685
+ # `KeyError` for any seed that happened to draw one. The census
686
+ # measures what a domain has; this names what a generator can
687
+ # write, and a form in the first without the second simply is not
688
+ # generated — the rotation still meets it, and
689
+ # `spec/combination_coverage_spec.rb`'s own `HELD_OUTSIDE_THE_GOLDENS`
690
+ # names where. Kept honest by `spec/fuzzing/domain_generator_spec.rb`.
691
+ FORMS = Builder::FORM_STEPS.keys.freeze
692
+ end
693
+ end
694
+ end