hecks 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (408) hide show
  1. checksums.yaml +5 -5
  2. data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
  3. data/lib/hecks/adapters/driven/claude_code.rb +127 -0
  4. data/lib/hecks/adapters/driven/d1.adapter +12 -0
  5. data/lib/hecks/adapters/driven/d1.rb +403 -0
  6. data/lib/hecks/adapters/driven/folder.adapter +3 -0
  7. data/lib/hecks/adapters/driven/folder.rb +199 -0
  8. data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
  9. data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
  10. data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
  11. data/lib/hecks/adapters/driven/governance_authorization.rb +62 -0
  12. data/lib/hecks/adapters/driven/heki/journal.rb +51 -0
  13. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  14. data/lib/hecks/adapters/driven/heki/snapshot.rb +38 -0
  15. data/lib/hecks/adapters/driven/heki.adapter +4 -0
  16. data/lib/hecks/adapters/driven/heki.rb +148 -0
  17. data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
  18. data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
  19. data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
  20. data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
  21. data/lib/hecks/adapters/driven/lambda.adapter +4 -0
  22. data/lib/hecks/adapters/driven/lambda.rb +131 -0
  23. data/lib/hecks/adapters/driven/memory.adapter +3 -0
  24. data/lib/hecks/adapters/driven/memory.rb +103 -0
  25. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
  26. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
  27. data/lib/hecks/adapters/driven/postgres/codec.rb +80 -0
  28. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +168 -0
  29. data/lib/hecks/adapters/driven/postgres.adapter +5 -0
  30. data/lib/hecks/adapters/driven/postgres.rb +391 -0
  31. data/lib/hecks/adapters/driven/postgres_era/lineage/era_store.rb +171 -0
  32. data/lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb +190 -0
  33. data/lib/hecks/adapters/driven/postgres_era/lineage/head_compiler.rb +429 -0
  34. data/lib/hecks/adapters/driven/postgres_era/lineage/mint_transaction.rb +166 -0
  35. data/lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb +286 -0
  36. data/lib/hecks/adapters/driven/postgres_era/lineage/resumable_backfill.rb +168 -0
  37. data/lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb +163 -0
  38. data/lib/hecks/adapters/driven/postgres_era/lineage/transform_installer.rb +114 -0
  39. data/lib/hecks/adapters/driven/postgres_era/lineage.rb +137 -0
  40. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/coverage_check.rb +89 -0
  41. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb +82 -0
  42. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
  43. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/minter.rb +132 -0
  44. data/lib/hecks/adapters/driven/postgres_era/lineage_manager.rb +74 -0
  45. data/lib/hecks/adapters/driven/postgres_era.adapter +6 -0
  46. data/lib/hecks/adapters/driven/postgres_era.rb +671 -0
  47. data/lib/hecks/adapters/driven/prism.adapter +3 -0
  48. data/lib/hecks/adapters/driven/prism.rb +80 -0
  49. data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
  50. data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
  51. data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
  52. data/lib/hecks/adapters/driven/sqlite/codec.rb +63 -0
  53. data/lib/hecks/adapters/driven/sqlite/projection.rb +105 -0
  54. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +180 -0
  55. data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
  56. data/lib/hecks/adapters/driven/sqlite.rb +285 -0
  57. data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
  58. data/lib/hecks/adapters/driven/system_clock.rb +14 -0
  59. data/lib/hecks/adapters/driven.rb +36 -0
  60. data/lib/hecks/adapters.rb +6 -0
  61. data/lib/hecks/behaviors/dsl.rb +96 -0
  62. data/lib/hecks/behaviors/expectations.rb +280 -0
  63. data/lib/hecks/behaviors/ir.rb +31 -0
  64. data/lib/hecks/behaviors/rspec.rb +42 -0
  65. data/lib/hecks/behaviors/runner.rb +96 -0
  66. data/lib/hecks/behaviors.rb +25 -0
  67. data/lib/hecks/bluebook/aggregate.rb +108 -0
  68. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
  69. data/lib/hecks/bluebook/assembly/build.rb +48 -0
  70. data/lib/hecks/bluebook/assembly/contract.rb +112 -0
  71. data/lib/hecks/bluebook/assembly/contracts.rb +409 -0
  72. data/lib/hecks/bluebook/assembly/marks.rb +227 -0
  73. data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
  74. data/lib/hecks/bluebook/assembly.rb +78 -0
  75. data/lib/hecks/bluebook/attribute.rb +96 -0
  76. data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
  77. data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
  78. data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
  79. data/lib/hecks/bluebook/behaviour/command.rb +116 -0
  80. data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
  81. data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
  82. data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
  83. data/lib/hecks/bluebook/behaviour/lifecycle.rb +51 -0
  84. data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
  85. data/lib/hecks/bluebook/behaviour/process_manager.rb +38 -0
  86. data/lib/hecks/bluebook/behaviour/query.rb +10 -0
  87. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
  88. data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
  89. data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
  90. data/lib/hecks/bluebook/chapter.rb +68 -0
  91. data/lib/hecks/bluebook/command.rb +124 -0
  92. data/lib/hecks/bluebook/domain_port.rb +102 -0
  93. data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
  94. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +906 -0
  95. data/lib/hecks/bluebook/dsl/attribute_collector.rb +327 -0
  96. data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
  97. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1018 -0
  98. data/lib/hecks/bluebook/dsl/command_builder.rb +626 -0
  99. data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
  100. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
  101. data/lib/hecks/bluebook/dsl/entity_builder.rb +305 -0
  102. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
  103. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
  104. data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
  105. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
  106. data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
  107. data/lib/hecks/bluebook/dsl/policy_builder.rb +120 -0
  108. data/lib/hecks/bluebook/dsl/port_builder.rb +30 -0
  109. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
  110. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +207 -0
  111. data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
  112. data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
  113. data/lib/hecks/bluebook/dsl/rule_reference.rb +172 -0
  114. data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
  115. data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
  116. data/lib/hecks/bluebook/dsl/word_gate.rb +221 -0
  117. data/lib/hecks/bluebook/dsl/world_builder.rb +77 -0
  118. data/lib/hecks/bluebook/dsl.rb +45 -0
  119. data/lib/hecks/bluebook/entity.rb +103 -0
  120. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -0
  121. data/lib/hecks/bluebook/expression/evaluator.rb +261 -0
  122. data/lib/hecks/bluebook/expression/projection.json +170 -0
  123. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
  124. data/lib/hecks/bluebook/expression/resolver.rb +630 -0
  125. data/lib/hecks/bluebook/expression.rb +13 -0
  126. data/lib/hecks/bluebook/hexagon.rb +60 -0
  127. data/lib/hecks/bluebook/lifecycle.rb +42 -0
  128. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
  129. data/lib/hecks/bluebook/meta_validator/judge.rb +559 -0
  130. data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
  131. data/lib/hecks/bluebook/meta_validator/port_judge.rb +47 -0
  132. data/lib/hecks/bluebook/meta_validator/readings.rb +350 -0
  133. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +312 -0
  134. data/lib/hecks/bluebook/meta_validator/shapes.rb +246 -0
  135. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
  136. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
  137. data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
  138. data/lib/hecks/bluebook/meta_validator.rb +452 -0
  139. data/lib/hecks/bluebook/model_check.rb +318 -0
  140. data/lib/hecks/bluebook/pattern_subset.rb +120 -0
  141. data/lib/hecks/bluebook/policy.rb +41 -0
  142. data/lib/hecks/bluebook/process_manager.rb +86 -0
  143. data/lib/hecks/bluebook/project_discovery.rb +30 -0
  144. data/lib/hecks/bluebook/project_loader.rb +40 -0
  145. data/lib/hecks/bluebook/project_register.rb +82 -0
  146. data/lib/hecks/bluebook/query.rb +61 -0
  147. data/lib/hecks/bluebook/read_model.rb +109 -0
  148. data/lib/hecks/bluebook/reference.rb +74 -0
  149. data/lib/hecks/bluebook/smoke_test.rb +166 -0
  150. data/lib/hecks/bluebook/synthesizer.rb +95 -0
  151. data/lib/hecks/bluebook/translation.rb +92 -0
  152. data/lib/hecks/bluebook/value_object.rb +58 -0
  153. data/lib/hecks/bluebook.rb +74 -0
  154. data/lib/hecks/codemod.rb +342 -0
  155. data/lib/hecks/construct.rb +71 -0
  156. data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
  157. data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
  158. data/lib/hecks/deploy/oidc.json +18 -0
  159. data/lib/hecks/doc/reference.rb +389 -0
  160. data/lib/hecks/embryonaut_bluebook.rb +75 -0
  161. data/lib/hecks/facade/cli_door.rb +116 -0
  162. data/lib/hecks/facade/cli_runner.rb +190 -0
  163. data/lib/hecks/facade/command_request.rb +105 -0
  164. data/lib/hecks/facade/handle.rb +173 -0
  165. data/lib/hecks/facade/json_door.rb +154 -0
  166. data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
  167. data/lib/hecks/facade/surface/chapter.rb +107 -0
  168. data/lib/hecks/facade/surface.rb +48 -0
  169. data/lib/hecks/facade.rb +44 -0
  170. data/lib/hecks/forms/app.rb +300 -0
  171. data/lib/hecks/forms/command_form_renderer.rb +113 -0
  172. data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
  173. data/lib/hecks/forms/field_renderer.rb +170 -0
  174. data/lib/hecks/forms/field_shape.rb +232 -0
  175. data/lib/hecks/forms/html.rb +53 -0
  176. data/lib/hecks/forms/index_renderer.rb +35 -0
  177. data/lib/hecks/forms/page.rb +157 -0
  178. data/lib/hecks/forms/params.rb +132 -0
  179. data/lib/hecks/forms/query_form_renderer.rb +114 -0
  180. data/lib/hecks/forms/record_renderer.rb +115 -0
  181. data/lib/hecks/forms/record_table.rb +63 -0
  182. data/lib/hecks/forms/reference_options.rb +30 -0
  183. data/lib/hecks/forms/value_object_shape.rb +46 -0
  184. data/lib/hecks/forms.rb +54 -0
  185. data/lib/hecks/fqn.rb +94 -0
  186. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
  187. data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
  188. data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
  189. data/lib/hecks/framework/bluebook/governance.bluebook +130 -0
  190. data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
  191. data/lib/hecks/framework/oidc.json +39 -0
  192. data/lib/hecks/framework.rb +90 -0
  193. data/lib/hecks/freezer.rb +67 -0
  194. data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
  195. data/lib/hecks/fuzzing/isolated_boot.rb +92 -0
  196. data/lib/hecks/fuzzing/properties.rb +1146 -0
  197. data/lib/hecks/fuzzing/replay.rb +635 -0
  198. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
  199. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +54 -0
  200. data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
  201. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +185 -0
  202. data/lib/hecks/fuzzing/sequence_generator.rb +130 -0
  203. data/lib/hecks/fuzzing/value_generator.rb +177 -0
  204. data/lib/hecks/fuzzing.rb +12 -0
  205. data/lib/hecks/grammar/evolve.rb +291 -0
  206. data/lib/hecks/grammar/expression.bluebook +417 -0
  207. data/lib/hecks/grammar/expression_operators.json +1152 -0
  208. data/lib/hecks/grammar/grammar.hecksagon +20 -0
  209. data/lib/hecks/grammar/oidc.json +69 -0
  210. data/lib/hecks/grammar/translation.bluebook +203 -0
  211. data/lib/hecks/grammar.rb +178 -0
  212. data/lib/hecks/ir.rb +126 -0
  213. data/lib/hecks/language/adapter.bluebook +116 -0
  214. data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
  215. data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
  216. data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
  217. data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
  218. data/lib/hecks/language/bluebook/command.bluebook +437 -0
  219. data/lib/hecks/language/bluebook/entity.bluebook +316 -0
  220. data/lib/hecks/language/bluebook/policy.bluebook +172 -0
  221. data/lib/hecks/language/bluebook/process_manager.bluebook +264 -0
  222. data/lib/hecks/language/bluebook/projection.bluebook +267 -0
  223. data/lib/hecks/language/bluebook/query.bluebook +245 -0
  224. data/lib/hecks/language/bluebook/shape.bluebook +292 -0
  225. data/lib/hecks/language/bluebook/syntax.bluebook +447 -0
  226. data/lib/hecks/language/bluebook/vocabulary.bluebook +364 -0
  227. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
  228. data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
  229. data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
  230. data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
  231. data/lib/hecks/language/oidc.json +313 -0
  232. data/lib/hecks/language/port.bluebook +92 -0
  233. data/lib/hecks/language/translation/translation.bluebook +110 -0
  234. data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
  235. data/lib/hecks/language/world/wiring.bluebook +62 -0
  236. data/lib/hecks/language/world/world.bluebook +84 -0
  237. data/lib/hecks/literal.rb +125 -0
  238. data/lib/hecks/naming.rb +121 -0
  239. data/lib/hecks/ports/access_control.port +4 -0
  240. data/lib/hecks/ports/access_control.rb +62 -0
  241. data/lib/hecks/ports/agent/answers.rb +104 -0
  242. data/lib/hecks/ports/agent.port +4 -0
  243. data/lib/hecks/ports/agent.rb +167 -0
  244. data/lib/hecks/ports/authentication.port +4 -0
  245. data/lib/hecks/ports/authentication.rb +50 -0
  246. data/lib/hecks/ports/authorization.port +4 -0
  247. data/lib/hecks/ports/authorization.rb +59 -0
  248. data/lib/hecks/ports/clock.port +4 -0
  249. data/lib/hecks/ports/clock.rb +62 -0
  250. data/lib/hecks/ports/extraction.port +4 -0
  251. data/lib/hecks/ports/extraction.rb +37 -0
  252. data/lib/hecks/ports/identity_assignment.port +4 -0
  253. data/lib/hecks/ports/identity_assignment.rb +45 -0
  254. data/lib/hecks/ports/identity_generation.port +4 -0
  255. data/lib/hecks/ports/identity_generation.rb +49 -0
  256. data/lib/hecks/ports/identity_resolution.port +4 -0
  257. data/lib/hecks/ports/identity_resolution.rb +40 -0
  258. data/lib/hecks/ports/loading.port +4 -0
  259. data/lib/hecks/ports/loading.rb +13 -0
  260. data/lib/hecks/ports/persistence/append_only.rb +102 -0
  261. data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
  262. data/lib/hecks/ports/persistence/execution.rb +18 -0
  263. data/lib/hecks/ports/persistence/lineage.rb +277 -0
  264. data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
  265. data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
  266. data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
  267. data/lib/hecks/ports/persistence.port +4 -0
  268. data/lib/hecks/ports/persistence.rb +39 -0
  269. data/lib/hecks/ports/projection.port +4 -0
  270. data/lib/hecks/ports/projection.rb +76 -0
  271. data/lib/hecks/ports/query/in_memory.rb +60 -0
  272. data/lib/hecks/ports/query/ordering.rb +41 -0
  273. data/lib/hecks/ports/query.rb +36 -0
  274. data/lib/hecks/ports.rb +27 -0
  275. data/lib/hecks/projections/diagrams.rb +413 -0
  276. data/lib/hecks/projections/ir.rb +18 -0
  277. data/lib/hecks/projections/model/deviations.rb +98 -0
  278. data/lib/hecks/projections/model.rb +145 -0
  279. data/lib/hecks/projections/oidc.rb +110 -0
  280. data/lib/hecks/projections/parser_table.rb +159 -0
  281. data/lib/hecks/projections/reference.rb +38 -0
  282. data/lib/hecks/projections/shape.rb +31 -0
  283. data/lib/hecks/projections/statements.rb +110 -0
  284. data/lib/hecks/projections/vocabulary.rb +100 -0
  285. data/lib/hecks/projections.rb +32 -0
  286. data/lib/hecks/projector/cli_projector.rb +437 -0
  287. data/lib/hecks/projector/docs_projector.rb +321 -0
  288. data/lib/hecks/projector/exporter.rb +141 -0
  289. data/lib/hecks/projector/ir_projector.rb +18 -0
  290. data/lib/hecks/projector/narrate_projector.rb +243 -0
  291. data/lib/hecks/projector/target.rb +97 -0
  292. data/lib/hecks/projector.rb +186 -0
  293. data/lib/hecks/query_ir.rb +392 -0
  294. data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
  295. data/lib/hecks/query_specification/common/comparators.rb +25 -0
  296. data/lib/hecks/query_specification/common/comparison.rb +174 -0
  297. data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
  298. data/lib/hecks/query_specification/common/dsl.rb +58 -0
  299. data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
  300. data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
  301. data/lib/hecks/query_specification/common/null_policy.rb +80 -0
  302. data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
  303. data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
  304. data/lib/hecks/query_specification/common/options.rb +32 -0
  305. data/lib/hecks/query_specification/common/order_by.rb +9 -0
  306. data/lib/hecks/query_specification/common/specification.rb +10 -0
  307. data/lib/hecks/query_specification/common/where_clause.rb +9 -0
  308. data/lib/hecks/query_specification/field_path.rb +89 -0
  309. data/lib/hecks/query_specification/hop_path.rb +130 -0
  310. data/lib/hecks/query_specification/read_model/specification.rb +18 -0
  311. data/lib/hecks/query_specification.rb +14 -0
  312. data/lib/hecks/rendering.rb +48 -0
  313. data/lib/hecks/router/namespace_installer.rb +157 -0
  314. data/lib/hecks/router.rb +70 -0
  315. data/lib/hecks/runtime/caller.rb +50 -0
  316. data/lib/hecks/runtime/capability_graph.rb +44 -0
  317. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +124 -0
  318. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +248 -0
  319. data/lib/hecks/runtime/command_interpreter.rb +413 -0
  320. data/lib/hecks/runtime/command_rules/admissibility.rb +307 -0
  321. data/lib/hecks/runtime/command_rules/arithmetic.rb +254 -0
  322. data/lib/hecks/runtime/command_rules/authorization.rb +64 -0
  323. data/lib/hecks/runtime/command_rules/emission.rb +34 -0
  324. data/lib/hecks/runtime/command_rules/references.rb +181 -0
  325. data/lib/hecks/runtime/command_rules.rb +28 -0
  326. data/lib/hecks/runtime/dependency_planning.rb +231 -0
  327. data/lib/hecks/runtime/dispatcher.rb +286 -0
  328. data/lib/hecks/runtime/entity_element.rb +253 -0
  329. data/lib/hecks/runtime/entity_interpreter.rb +228 -0
  330. data/lib/hecks/runtime/era_check.rb +136 -0
  331. data/lib/hecks/runtime/era_guard/shape_diff.rb +128 -0
  332. data/lib/hecks/runtime/era_guard.rb +193 -0
  333. data/lib/hecks/runtime/era_tamper.rb +61 -0
  334. data/lib/hecks/runtime/errors.rb +100 -0
  335. data/lib/hecks/runtime/event.rb +51 -0
  336. data/lib/hecks/runtime/identity.rb +128 -0
  337. data/lib/hecks/runtime/instance.rb +120 -0
  338. data/lib/hecks/runtime/interpreting.rb +69 -0
  339. data/lib/hecks/runtime/loader.rb +143 -0
  340. data/lib/hecks/runtime/policy_interpreter.rb +366 -0
  341. data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
  342. data/lib/hecks/runtime/query_interpreter.rb +254 -0
  343. data/lib/hecks/runtime/reaction_invocation.rb +245 -0
  344. data/lib/hecks/runtime/read_model_interpreter.rb +266 -0
  345. data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
  346. data/lib/hecks/runtime/reference_hop.rb +99 -0
  347. data/lib/hecks/runtime/refusal_wording.rb +124 -0
  348. data/lib/hecks/runtime/registry/saga_persistence.rb +70 -0
  349. data/lib/hecks/runtime/registry/verification.rb +158 -0
  350. data/lib/hecks/runtime/registry.rb +218 -0
  351. data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
  352. data/lib/hecks/runtime/routing.rb +96 -0
  353. data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
  354. data/lib/hecks/runtime/saga_interpreter.rb +301 -0
  355. data/lib/hecks/runtime/storage_shape.rb +120 -0
  356. data/lib/hecks/runtime/tenant_check.rb +84 -0
  357. data/lib/hecks/runtime/tenant_scope.rb +55 -0
  358. data/lib/hecks/runtime/value/admission.rb +112 -0
  359. data/lib/hecks/runtime/value/coercion.rb +500 -0
  360. data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
  361. data/lib/hecks/runtime/value.rb +125 -0
  362. data/lib/hecks/runtime.rb +95 -0
  363. data/lib/hecks/translation/audit/approval_digest.rb +31 -0
  364. data/lib/hecks/translation/audit/layer_one.rb +37 -0
  365. data/lib/hecks/translation/audit/layer_two.rb +77 -0
  366. data/lib/hecks/translation/audit/unfed_report.rb +42 -0
  367. data/lib/hecks/translation/audit.rb +70 -0
  368. data/lib/hecks/translation/reattest.rb +72 -0
  369. data/lib/hecks/translation/rule_compiler.rb +120 -0
  370. data/lib/hecks/translation/scaffold/differ.rb +183 -0
  371. data/lib/hecks/translation/scaffold/renderer.rb +41 -0
  372. data/lib/hecks/translation/scaffold/writer.rb +28 -0
  373. data/lib/hecks/translation/scaffold.rb +30 -0
  374. data/lib/hecks/translation.rb +11 -0
  375. data/lib/hecks/version.rb +16 -0
  376. data/lib/hecks/vocabulary.rb +213 -0
  377. data/lib/hecks.rb +118 -6
  378. metadata +401 -222
  379. data/bin/hecks +0 -7
  380. data/bin/hecks-package +0 -65
  381. data/bin/hecks_console +0 -12
  382. data/bin/hecks_serverless +0 -6
  383. data/lib/cli/build.rb +0 -14
  384. data/lib/cli/command_runner.rb +0 -28
  385. data/lib/cli/console.rb +0 -10
  386. data/lib/cli/generate.rb +0 -37
  387. data/lib/cli/hecks-cli.rb +0 -27
  388. data/lib/cli/test.rb +0 -57
  389. data/lib/console/commands.rb +0 -8
  390. data/lib/console/hecks-console.rb +0 -1
  391. data/lib/packager/README.md +0 -0
  392. data/lib/packager/app_runner.rb +0 -21
  393. data/lib/packager/args.rb +0 -26
  394. data/lib/packager/compatibility/fixnum.rb +0 -6
  395. data/lib/packager/hecks.rb +0 -39
  396. data/lib/packager/query_runner.rb +0 -21
  397. data/lib/packager/resources/Dockerfile +0 -11
  398. data/lib/packager/resources/app_binary +0 -7
  399. data/lib/packager/resources/bundle_config +0 -3
  400. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
  401. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
  402. data/lib/serverless/Domain +0 -32
  403. data/lib/serverless/cli.rb +0 -75
  404. data/lib/serverless/resources/command_name.js +0 -5
  405. data/lib/serverless/resources/environment.js +0 -7
  406. data/lib/serverless/resources/handler.js.tt +0 -28
  407. data/lib/serverless/resources/run_binary.js +0 -22
  408. data/lib/serverless/resources/serverless.yml +0 -20
@@ -0,0 +1,1018 @@
1
+ require_relative "word_gate"
2
+ module Hecks
3
+ module Bluebook
4
+ module DSL
5
+ class BluebookBuilder
6
+ GRAMMAR_CONTEXT = "Bluebook"
7
+
8
+ include WordGate
9
+
10
+ attr_reader :classification
11
+
12
+ def initialize(name, version: nil)
13
+ @name = name
14
+ @version = version
15
+ @aggregates = []
16
+ @read_models = []
17
+ @policies = []
18
+ @process_managers = []
19
+ # THE ROOT of the CHAPTER-WIDE given pool — one level wider
20
+ # than `AggregateBuilder`'s own `@entity_named_givens` (S10
21
+ # extended across an aggregate's whole entity tree, earlier
22
+ # this arc). See `#given`'s own comment for what this closes.
23
+ @chapter_named_givens = {}
24
+ # EVERY BARE CHAPTER-GIVEN REFERENCE THIS CHAPTER'S OWN FILES
25
+ # LEFT UNRESOLVED SO FAR — threaded into every aggregate the
26
+ # same way `@chapter_named_givens` is. See
27
+ # `AggregateBuilder#pending_chapter_given`'s own comment for
28
+ # what queues here and `#resolve_pending_chapter_givens!`,
29
+ # below, for where it drains.
30
+ @chapter_pending_givens = []
31
+ end
32
+
33
+ # Chapter metadata belongs to the composed folder, not whichever file
34
+ # happened to sort first. A later concept file may be the one carrying
35
+ # the version header; adopt it into the already-open chapter builder.
36
+ # Two different versions are a real contradiction, not load order.
37
+ def adopt_version(version)
38
+ return if version.nil?
39
+ if @version && @version.to_s != version.to_s
40
+ raise Malformed, "#{@name} declares both version #{@version.inspect} and #{version.inspect}"
41
+ end
42
+
43
+ @version = version
44
+ end
45
+ private :adopt_version
46
+
47
+ def vision(value)
48
+ # moved to the language: Vision invariant, on Chapter.Declare
49
+
50
+ @vision = value
51
+ end
52
+
53
+ # A domain's own identity can change — this names what it used to
54
+ # be, so the storage layer can recognize its own history under the
55
+ # old name instead of minting a brand-new lineage from nothing.
56
+ def formerly_known_as(value) = @formerly_known_as = value.to_s
57
+
58
+ # A SUB-LANGUAGE NAMES WHERE IT LANDS. ADR 0026's own seam: the core
59
+ # grammar does not name its extension points, so this chapter names
60
+ # ITSELF onto them instead — the core contexts (e.g. "Query",
61
+ # "ReadModel") whose own admitted words this chapter's `Syntax`
62
+ # aggregate contributes rows for. Variadic, and accumulating across
63
+ # calls the same reason `identified_by`/`group_by` are: nothing here
64
+ # requires one call to name every context at once.
65
+ # RENAMED FROM `attaches_to` — item #13's full metaprogrammed
66
+ # dispatch (slice 4c). Not bootstrap-reachable (only sub-language
67
+ # chapters like Paging use it; the CORE chapters never describe
68
+ # themselves with it).
69
+ def attaches_to_impl(*contexts) = (@attaches_to ||= []).concat(contexts.map(&:to_s))
70
+
71
+ def core = @classification = :core
72
+ def supporting = @classification = :supporting
73
+ def generic = @classification = :generic
74
+
75
+ # `@chapter_named_givens` is threaded into every aggregate this
76
+ # chapter builds — see `AggregateBuilder#given`'s own comment
77
+ # for the sharing this enables; NOT a new top-level DSL word
78
+ # itself (an aggregate's own EXISTING `given` already both
79
+ # declares locally and write-throughs here as a side effect,
80
+ # the identical shape `EntityBuilder#given`'s own write-through
81
+ # to its owner aggregate's pool already takes — no new spelling
82
+ # for "declare a precondition," one level wider, same word).
83
+ # RENAMED FROM `aggregate` — item #13's full metaprogrammed
84
+ # dispatch (slice 4c). Bootstrap-reachable (every core/attached
85
+ # chapter's own top-level shape is written with it), so also
86
+ # named in GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.
87
+ def aggregate_impl(name, &block)
88
+ @aggregates << AggregateBuilder.build(name, chapter_named_givens: @chapter_named_givens,
89
+ chapter_pending_givens: @chapter_pending_givens, &block)
90
+ end
91
+
92
+ # `read_model` is the word (ADR 0025 reverts `report` — the IR
93
+ # construct, the registry API, and the docs filename all said
94
+ # `read_model` the whole time; no era was ever minted under
95
+ # `report`, so this is history and source agreeing again). `report`
96
+ # stays answered under `MetaValidator.shadow_parsing?` (S0a's own
97
+ # bridge) for the same reason `has_many` does — frozen era text
98
+ # that used it must keep booting; live source refuses it, naming
99
+ # the replacement.
100
+ def read_model(name, &block)
101
+ # A read model gathers heads from SEVERAL aggregates, so no single head
102
+ # declares it — the chapter does. Its owner is stamped in `build`, where
103
+ # the chapter namespace exists.
104
+ @read_models << ReadModelBuilder.build(name, &block)
105
+ end
106
+
107
+ def report(name, &block)
108
+ return read_model(name, &block) if MetaValidator.shadow_parsing?
109
+
110
+ raise Malformed, "report is gone — read_model is the word now"
111
+ end
112
+
113
+ def policy(name, &block)
114
+ @policies << PolicyBuilder.build(name, &block)
115
+ end
116
+
117
+ def process_manager(name, &block)
118
+ @process_managers << ProcessManagerBuilder.build(name, &block)
119
+ end
120
+
121
+ def build
122
+ # The chapter is the top of the construct chain — `Bluebook` is a
123
+ # ROOT, and its constructor stamps every aggregate and read model with
124
+ # itself as owner, so every `hecks_fqn` below resolves by walking up
125
+ # to it. No constants are installed at load time : the public door is
126
+ # a per-boot projection, installed by `Loader.bind_runtime` once a
127
+ # dispatcher exists to close over (facade/surface.rb).
128
+ bluebook = Bluebook::Chapter.new(name: @name, version: @version, vision: @vision,
129
+ aggregates: @aggregates,
130
+ read_models: @read_models,
131
+ policies: @aggregates.flat_map(&:policies) + @policies,
132
+ process_managers: @process_managers,
133
+ classification: @classification,
134
+ formerly_known_as: @formerly_known_as,
135
+ attaches_to: @attaches_to || [])
136
+
137
+ # SAME REASON, SAME GATE — a bare chapter-given may still be
138
+ # pending (see `AggregateBuilder#pending_chapter_given`) if a
139
+ # file that would resolve it hasn't loaded yet; resolving now
140
+ # would see the same incomplete `@chapter_named_givens`
141
+ # `validate_assembled!` below would. Deferred to
142
+ # `MetaValidator.judge_deferred!` the same way, and BEFORE
143
+ # `validate_assembled!` there — nothing downstream should ever
144
+ # read an unresolved placeholder's fields.
145
+ resolve_pending_chapter_givens! unless MetaValidator.deferring?
146
+
147
+ # A CHAPTER MAY BE SPLIT ACROSS FILES (see `self.build`'s own
148
+ # comment). Every check below needs the WHOLE chapter present —
149
+ # a hop, a projection, a correlation key or an event shape can
150
+ # equally name a construct declared in a file that has not
151
+ # loaded yet, and `@aggregates`/`@process_managers` here are
152
+ # only ever as complete as whatever has loaded SO FAR. So,
153
+ # exactly like `MetaValidator.call` below, this is skipped
154
+ # while `MetaValidator.defer` is loading the chapter's files
155
+ # and run once instead — by `MetaValidator.judge_deferred!`,
156
+ # against the fully assembled chapter — after the last one
157
+ # loads. A single-file chapter (still the common case) never
158
+ # sees `deferring?` true here at all, so its own checks still
159
+ # run inline, exactly as before.
160
+ self.class.validate_assembled!(bluebook) unless MetaValidator.deferring?
161
+
162
+ # The language judges the bluebook, in the language. Last, so the
163
+ # meta-domain sees a fully built IR — the whole-document rules need
164
+ # every declaration present, which is why they cannot be givens fired
165
+ # at declaration time.
166
+ MetaValidator.call(bluebook)
167
+ end
168
+
169
+ # THE OTHER HALF OF A CHAPTER-WIDE `given` REFERENCE —
170
+ # `AggregateBuilder#pending_chapter_given` recognised an
171
+ # unresolved bare reference and deferred it here, unable to
172
+ # check further: a later file in this SAME chapter might still
173
+ # declare the real thing. Runs once every file has loaded,
174
+ # against the now-complete `@chapter_named_givens` pool — the
175
+ # IDENTICAL lookup `reference_named_chapter_given` already does,
176
+ # just late enough to see every aggregate's own declarations,
177
+ # not only the ones loaded before the referencing one.
178
+ #
179
+ # MUTATES each placeholder `Given` IN PLACE rather than
180
+ # replacing it — it is already embedded, by Ruby object
181
+ # reference, in the referencing aggregate's own `preconditions`
182
+ # and in any command (same aggregate) that separately
183
+ # bare-referenced the same description, so there is nothing
184
+ # downstream holding a second, now-stale copy to update.
185
+ # Instance-level (not `self.`, unlike `validate_assembled!`) —
186
+ # unlike that battery, this needs `@chapter_named_givens` itself,
187
+ # which only exists on the builder instance still open for this
188
+ # chapter (`MetaValidator.judge_deferred!` reaches it via
189
+ # `registry.bluebook_builder(name)`, guaranteed already present).
190
+ def resolve_pending_chapter_givens!
191
+ @chapter_pending_givens.each do |entry|
192
+ resolved = resolve_pending_chapter_given(entry)
193
+ entry[:placeholder].description = resolved.description
194
+ entry[:placeholder].canonical = resolved.canonical
195
+ entry[:placeholder].predicate = resolved.predicate
196
+ end
197
+ @chapter_pending_givens.clear
198
+ end
199
+
200
+ def resolve_pending_chapter_given(entry)
201
+ description = entry[:description]
202
+ candidates = RuleReference.resolve_owner_keyed(@chapter_named_givens, description)
203
+
204
+ if entry[:declared_by]
205
+ candidates[entry[:declared_by]] ||
206
+ raise(Malformed,
207
+ "#{entry[:aggregate]}'s given #{description.inspect} names no precondition " \
208
+ "#{entry[:declared_by]} declares in this chapter — #{entry[:declared_by]} " \
209
+ "either hasn't declared #{description.inspect}, or declared_by: named the " \
210
+ "wrong aggregate")
211
+ elsif candidates.size == 1
212
+ candidates.values.first
213
+ elsif candidates.empty?
214
+ raise(Malformed,
215
+ "#{entry[:aggregate]}'s given #{description.inspect} names no precondition " \
216
+ "any aggregate in this chapter ever declares — declare it once with a block " \
217
+ "(some aggregate's own given(#{description.inspect}) { ... })")
218
+ else
219
+ raise(Malformed,
220
+ "#{entry[:aggregate]}'s given #{description.inspect} is ambiguous in this " \
221
+ "chapter — #{candidates.keys.join(', ')} each declare a DIFFERENT predicate " \
222
+ "under this same description; name which one with declared_by: (e.g. " \
223
+ "given(#{description.inspect}, declared_by: #{candidates.keys.first}))")
224
+ end
225
+ end
226
+ private :resolve_pending_chapter_given
227
+
228
+ # EVERY WHOLE-CHAPTER CHECK, IN ONE PLACE — the battery `#build`
229
+ # used to run inline, now a pure function of an assembled
230
+ # `Bluebook::Chapter` so `MetaValidator.judge_deferred!` can run
231
+ # it too, once, on a chapter whose files have ALL loaded (see
232
+ # `#build`'s own comment for why that split exists at all).
233
+ # Public, not `private_class_method`'d, for exactly that second
234
+ # caller — `MetaValidator` needs to reach this with no builder
235
+ # instance in hand, only the chapter `judge_deferred!` already
236
+ # read back out of the registry.
237
+ def self.validate_assembled!(bluebook)
238
+ # moved to the language: an attribute type is a reference to its Shape,
239
+ # so an undeclared value object fails reference resolution
240
+ validate_reference_value_objects!(bluebook.aggregates)
241
+ validate_correlation_keys!(bluebook.process_managers, bluebook.aggregates)
242
+ validate_no_bidirectional_references!(bluebook.aggregates)
243
+ unless MetaValidator.shadow_parsing?
244
+ validate_event_shapes!(bluebook.aggregates)
245
+ validate_with_projections!(bluebook.policies, bluebook.process_managers, bluebook.aggregates)
246
+ end
247
+
248
+ # Every hop AggregateBuilder#seal_query_field recognised and
249
+ # deferred gets checked for real here — the earliest point a
250
+ # hop CAN be checked, for exactly the reason
251
+ # validate_no_bidirectional_references! above already gives:
252
+ # `Bluebook.new` just stamped `hecks_owner` on every
253
+ # aggregate, so `Reference#resolve` finally has a chapter to
254
+ # walk. Before this line every target in the file (including
255
+ # ones declared ABOVE the aggregate doing the asking) would
256
+ # have resolved to nil.
257
+ infer_hop_query_arguments!(bluebook)
258
+ validate_query_hops!(bluebook)
259
+
260
+ # Same precondition, same reason: a `projects` declaration's
261
+ # own reference cannot resolve until every aggregate in the
262
+ # chapter is real and owner-stamped (S12, ADR 0025).
263
+ validate_projected_fields!(bluebook)
264
+ end
265
+
266
+ # AN ENTITY COMMAND MAY NOT NAME ITSELF AS ITS ROOT.
267
+ #
268
+ # That is the whole of what is left here, and it needs saying plainly
269
+ # because the sentence this used to raise — "references must target
270
+ # aggregate heads" — was never what it checked.
271
+ #
272
+ # `CommandBuilder#reference_to` sets `references` ONLY when the target's
273
+ # bare name equals the owner's ; anything else becomes a reference
274
+ # ATTRIBUTE. So on an aggregate command `references` is always a copy of
275
+ # that aggregate's own name, and looking it up in an index of aggregates
276
+ # is a TAUTOLOGY — that branch never refused anything and structurally
277
+ # could not. Verified across all eight golden chapters before deleting it.
278
+ #
279
+ # On a PIECE's command the owner is the entity, and an entity is not a
280
+ # head, so what this actually refuses is `reference_to <its own name>`
281
+ # written inside `entity do … end`. A piece is reached THROUGH its
282
+ # aggregate ; a command on one addresses the aggregate, never the piece.
283
+ #
284
+ # Reference ATTRIBUTES are the language's business now — offered as the
285
+ # head's own id and resolved as references, so `Aggregate.Reference` and
286
+ # `Command.Reference` refuse an undeclared head with no predicate at all.
287
+ def self.validate_reference_value_objects!(aggregates)
288
+ heads = aggregates.map(&:hecks_name)
289
+
290
+ violations = aggregates.flat_map do |aggregate|
291
+ aggregate.entities.flat_map do |entity|
292
+ entity.commands.filter_map do |command|
293
+ next unless command.references
294
+ next if heads.include?(command.references.to_s)
295
+
296
+ "#{aggregate.hecks_name}.#{entity.hecks_name}.#{command.hecks_name} names itself as its root"
297
+ end
298
+ end
299
+ end
300
+
301
+ return if violations.empty?
302
+
303
+ raise Malformed,
304
+ "an entity command is addressed through its aggregate; #{violations.uniq.join('; ')}"
305
+ end
306
+
307
+ # EVENTS ARE FIRST-CLASS BY CONVENTION, NOT BY DECLARATION (ADR
308
+ # 0025, "events and reactions" — "a domain event is a value
309
+ # object with its own attributes, not a label"). No new `event
310
+ # do ... end` construct exists to hand-author and keep in step
311
+ # with every emitting command by hand — an event's own known
312
+ # shape IS whichever command(s) declare `emits` for its name, and
313
+ # this is the ONE thing that has to hold for that convention to
314
+ # mean anything: every command that emits a given name has to
315
+ # agree on what it carries. An event is one fact; a fact does not
316
+ # carry two different truths depending on who is telling it.
317
+ #
318
+ # STRUCTURAL fields only (name/type/list/optional) — `pattern:`/
319
+ # `admits:`/`default:` are refinements ON a field, not a second
320
+ # claim about what the payload holds, so two emitting commands
321
+ # are free to differ there without actually disagreeing about
322
+ # the event's own shape.
323
+ def self.validate_event_shapes!(aggregates)
324
+ event_emitters(aggregates).each do |event_name, pairs|
325
+ next if pairs.size == 1
326
+
327
+ shapes = pairs.map { |(owner, command)| event_shape(command, owner_aggregate(owner, aggregates)) }.uniq
328
+ next if shapes.size == 1
329
+
330
+ named = pairs.map { |(owner, command)| "#{owner}.#{command.hecks_name}" }.sort
331
+ raise Malformed,
332
+ "#{event_name.inspect} is emitted with different shapes by #{named.join(' and ')} — " \
333
+ "an event is one fact, and every command that emits it must declare the same fields"
334
+ end
335
+ end
336
+
337
+ # THE "EXPENSIVE HALF" the ADR names: "with: { account: :account }
338
+ # projecting into a reaction that has no declared contract ...
339
+ # breaks at dispatch rather than at load." Checked here, now that
340
+ # `validate_event_shapes!` (above) guarantees at most one real
341
+ # shape per event name, and command references being first-class
342
+ # (`Naming.command_ref`) means the TARGET side is a real
343
+ # resolvable command, not a string that might be a typo.
344
+ #
345
+ # SAME-CHAPTER ONLY, ON PURPOSE — a `with:` whose source event or
346
+ # target command lives outside this chapter (an `across` policy
347
+ # reacting to another domain's event entirely) is silently left
348
+ # unchecked rather than refused: there is nothing here yet to
349
+ # check it against, and "unresolvable" is not the same claim as
350
+ # "wrong."
351
+ #
352
+ # A FOR_EACH POLICY'S SOURCE ISN'T THE EVENT AT ALL — a fan-out
353
+ # `with:`'s symbols read the QUERY ROW `for_each` answers
354
+ # (FreezeAccountsOnSuspension's own comment: "`account` is the
355
+ # key the fan-out merges for each row"), which this has no shape
356
+ # for; the SOURCE half is skipped for those, the TARGET half
357
+ # (does the dispatched command actually declare the field) still
358
+ # runs, since that half is true regardless of where the value
359
+ # came from.
360
+ def self.validate_with_projections!(policies, process_managers, aggregates)
361
+ lookup = command_lookup(aggregates)
362
+ heads = correlation_heads(process_managers)
363
+
364
+ policies.each do |policy|
365
+ next if policy.with_spec.to_a.empty?
366
+
367
+ source_event = policy.for_each.to_s.empty? ? policy.on_event : nil
368
+ check_with_spec!(policy.trigger_command, source_event, policy.with_spec, lookup,
369
+ "#{policy.name}'s trigger", aggregates, heads)
370
+ end
371
+
372
+ process_managers.each do |pm|
373
+ pm.handlers.each do |handler|
374
+ handler.dispatches.each do |dispatch|
375
+ next if dispatch.with_spec.to_a.empty?
376
+
377
+ check_with_spec!(dispatch.command_name, handler.event_type, dispatch.with_spec, lookup,
378
+ "#{pm.name}'s dispatch #{dispatch.command_name}", aggregates, heads, pm: pm)
379
+ end
380
+ end
381
+ end
382
+ end
383
+
384
+ # `pm:` is present only for a process manager's own dispatch — a
385
+ # saga leg's source symbol resolves against the CURRENT triggering
386
+ # event first, same as a policy, but falls all the way back to the
387
+ # saga's own MEMORY when the current event does not carry it
388
+ # (`SagaInterpreter#dispatch_args`, its own last `else`) — and
389
+ # memory starts as the OPENING event's payload
390
+ # (`SagaInterpreter#instance = { ..., memory: event.payload }`,
391
+ # never updated after), never the leg's own. Settlement's own
392
+ # comment names exactly this: "the credit leg reads a destination
393
+ # no event carried" — `AccountDebited` never declares `:reference`,
394
+ # only `TransferRequested` (`pm.starts_on`) does, and that is
395
+ # where the value is genuinely still coming from.
396
+ def self.check_with_spec!(command_ref, event_name, with_spec, lookup, label, aggregates, correlation_heads, pm: nil)
397
+ target = lookup[command_ref]
398
+ source_shape = event_name && event_shape_for(event_name, aggregates)
399
+ memory_shape = pm && event_shape_for(pm.starts_on, aggregates)
400
+ correlation = pm && pm.correlates_by && pm.correlation_head
401
+ # A POLICY'S SOURCE ALSO CARRIES THE EMITTER'S OWN IDENTITY —
402
+ # `PolicyInterpreter#emitter_identity`, the runtime half of this.
403
+ # An entity command's event never declares its aggregate's
404
+ # identity (it arrives through `reference_to`, not an
405
+ # `attribute`), so before this a policy on `KnightCaptured` could
406
+ # not spell `with: { label: :label }` at all — "reads :label off
407
+ # KnightCaptured, which does not declare it" — and chess's
408
+ # AdvancePly grew optional, unread attributes just to survive a
409
+ # wholesale forward. Policies only: a saga leg's own source is
410
+ # `SagaInterpreter#dispatch_args`, which merges no such thing.
411
+ identity_sources = pm.nil? && event_name ? event_identity_heads_for(event_name, aggregates) : []
412
+
413
+ with_spec.each do |field, source|
414
+ raise Malformed, "#{label}'s with: names #{field.inspect}, which #{command_ref} does not declare" if target && !command_declares?(target, field, aggregates, correlation_heads)
415
+
416
+ next unless source.is_a?(::Symbol)
417
+ next if source == correlation
418
+ next if identity_sources.include?(source)
419
+ next unless source_shape || memory_shape
420
+
421
+ found = [source_shape, memory_shape].compact.any? { |shape| shape.any? { |name, *| name == source } }
422
+ next if found
423
+
424
+ raise Malformed, "#{label}'s with: reads :#{source} off #{event_name.inspect}, which does not declare it"
425
+ end
426
+ end
427
+
428
+ # A command's OWN `reference_to` (bare, no `as:`) never lands in
429
+ # `attributes` — `CommandBuilder#reference_to`'s self-reference
430
+ # branch sets `command.references` instead (S2), and mints no new
431
+ # field at all. What addresses it is not one name but the SAME
432
+ # SET `CommandInterpreter::ArgumentGate#refuse_unknown_arguments`
433
+ # already accepts at dispatch time — `:id`, the owning aggregate's
434
+ # own `identity_heads` (real corpus proof — `Account.Debit`
435
+ # dispatched everywhere as `number: ...`, `Account`'s own
436
+ # `identified_by`), AND `Naming.reference_key(command.references)`
437
+ # (real corpus proof — `FreezeAccountsOnSuspension`'s `for_each`
438
+ # fan-out, whose own comment reads "`account` is the key the
439
+ # fan-out merges for each row it answers"). Both are simultaneously
440
+ # legal there, not context-dependent alternatives, so both are
441
+ # legal here : this mirrors that gate rather than re-deriving a
442
+ # narrower rule that would refuse one of two real, already-shipped
443
+ # dispatch conventions.
444
+ def self.command_declares?(command, field, aggregates, correlation_heads)
445
+ return true if command.attributes.any? { |a| a.name == field }
446
+ return true if field == :id
447
+ return true if correlation_heads.include?(field)
448
+ return false unless command.references
449
+
450
+ referenced = aggregates.find { |a| a.hecks_name == command.references }
451
+ return false unless referenced
452
+
453
+ referenced.identity_heads.include?(field) || Naming.reference_key(command.references) == field
454
+ end
455
+
456
+ # THE FOURTH addressing key `ArgumentGate#refuse_unknown_arguments`
457
+ # accepts, alongside `:id`/`identity_heads`/`reference_key` — every
458
+ # saga in THIS domain's own `correlates_by` head, carried through
459
+ # every dispatch as pure passthrough (Settlement's own comment:
460
+ # "`reference:` carries the correlation forward... this is pure
461
+ # passthrough, not an addressing key"). A command declaring none of
462
+ # its attributes named this is not a gap; the correlation key rides
463
+ # through commands that never read it, same as it does at runtime.
464
+ def self.correlation_heads(process_managers)
465
+ process_managers.filter_map { |pm| pm.correlates_by && pm.correlation_head }
466
+ end
467
+
468
+ # Every command this chapter declares, an aggregate's own AND
469
+ # every entity nested inside one, paired with a name for what
470
+ # declares it — shared by `validate_event_shapes!` and
471
+ # `validate_with_projections!`'s own command lookup, the same
472
+ # reach `HecksagonBuilder#commands_in` needs one level up (S8).
473
+ def self.each_command(aggregates)
474
+ return enum_for(:each_command, aggregates) unless block_given?
475
+
476
+ aggregates.each do |aggregate|
477
+ aggregate.commands.each { |command| yield aggregate.hecks_name, command }
478
+ aggregate.entities.each do |entity|
479
+ entity.commands.each { |command| yield "#{aggregate.hecks_name}.#{entity.hecks_name}", command }
480
+ end
481
+ end
482
+ end
483
+
484
+ # NOT MEMOISED — this used to be `@event_emitters ||=` on the
485
+ # builder instance, which is safe for a one-file chapter but
486
+ # wrong for one split across several: the FIRST file's build()
487
+ # call would compute and cache it from whatever `@aggregates`
488
+ # held at that moment, and every later file's own validation
489
+ # would keep reading that same stale snapshot, silently missing
490
+ # any command a later file adds. Recomputed fresh every call
491
+ # instead — this walks the whole chapter once per `#build`, not
492
+ # a hot path worth memoising at that cost.
493
+ def self.event_emitters(aggregates)
494
+ each_command(aggregates).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(owner, command), index|
495
+ command.emits.each { |event_name| index[event_name] << [owner, command] }
496
+ end
497
+ end
498
+
499
+ # STRUCTURAL, NOT NOMINAL. Two commands on two different
500
+ # aggregates that both `emits "SameEvent"` are free to type a
501
+ # field through two DIFFERENT, locally-scoped wrapper value
502
+ # objects (e.g. one aggregate's own `value: SomeText` vs
503
+ # another's `value: OtherText`, exactly the per-aggregate "own
504
+ # text VO" convention every aggregate in this grammar already
505
+ # follows for everything from `RuleText` to `FieldRef`) without
506
+ # actually disagreeing about the event's shape — comparing
507
+ # `a.type` by NAME would flag that as a violation for no real
508
+ # reason: an event is one fact, and two isomorphic wrapper types
509
+ # tell an identical one. So a value-object type is unwrapped to
510
+ # its OWN attribute shape (recursively — a wrapper could itself
511
+ # wrap another) before comparing, and only a primitive type
512
+ # (nothing left to unwrap) or two VOs that truly differ once
513
+ # unwrapped still counts as a real mismatch. `owner` carries the
514
+ # type's `value_object` lookup — a command's own attributes only
515
+ # know their type's NAME, never the aggregate that declared it,
516
+ # and two sibling aggregates in one chapter each keep a
517
+ # same-named VO private to themselves, so the unwrap has to ask
518
+ # the SAME aggregate the field's own command belongs to, never a
519
+ # neighbor's.
520
+ def self.event_shape(command, owner)
521
+ command.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s), a.list?, a.optional?] }.sort
522
+ end
523
+
524
+ def self.unwrap_shape(owner, type_name, seen = [])
525
+ return type_name if owner.nil? # owner couldn't be resolved -- compare by name, same as before this unwrap existed
526
+ return type_name if Attribute::PRIMITIVES.include?(type_name)
527
+ return type_name if seen.include?(type_name) # a self-referential VO bottoms out on its own name, not an infinite unwrap
528
+
529
+ shape = owner.value_object(type_name)
530
+ return type_name unless shape # not this owner's own VO (a reference type, say) -- nothing further to unwrap
531
+
532
+ shape.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s, seen + [type_name]), a.list?, a.optional?] }.sort
533
+ end
534
+
535
+ # `owner` (from `each_command`) is a plain STRING — the aggregate's
536
+ # `hecks_name` alone, or `"Aggregate.Entity"` for an entity's own
537
+ # command. Either way the VALUE OBJECTS a command's fields can be
538
+ # typed with are the AGGREGATE's own (`Entity` carries no
539
+ # `value_object` lookup of its own — the whole rest of this file
540
+ # already resolves hop/type lookups only at the aggregate level,
541
+ # e.g. `validate_hop_tail!`'s `target.value_object(type)`), so only
542
+ # the first segment ever matters here.
543
+ def self.owner_aggregate(owner, aggregates)
544
+ aggregates.find { |a| a.hecks_name == owner.to_s.split(".").first }
545
+ end
546
+
547
+ def self.event_shape_for(event_name, aggregates)
548
+ pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
549
+ return nil if pairs.empty?
550
+
551
+ owner_name, command = pairs.first
552
+ event_shape(command, owner_aggregate(owner_name, aggregates))
553
+ end
554
+
555
+ # The identity heads of the aggregate that emits `event_name` — an
556
+ # entity's event is stamped with its OWNING aggregate's identity
557
+ # (`Event#id` is the parent's), so an owner spelled "Game.Knight"
558
+ # answers Game's heads.
559
+ def self.event_identity_heads_for(event_name, aggregates)
560
+ pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
561
+ return [] if pairs.empty?
562
+
563
+ owner_name, = pairs.first
564
+ aggregate = owner_aggregate(owner_name, aggregates)
565
+ return [] unless aggregate
566
+
567
+ heads = aggregate.identity_heads.map(&:to_sym)
568
+ # AN ENTITY'S EVENT ALSO CARRIES THE PIECE'S OWN IDENTITY — the
569
+ # args a piece was addressed by are the args its event announces
570
+ # (`Emission#emit`: `payload: args`), so `id`-shaped heads are
571
+ # genuinely there at runtime even though no `attribute` line on
572
+ # the entity command declares them.
573
+ entity_names = owner_name.to_s.split(".").drop(1)
574
+ entity = entity_names.reduce(aggregate) { |owner, name| owner&.entities&.find { |e| e.hecks_name == name } }
575
+ heads + (entity ? entity.identity_heads.map(&:to_sym) : [])
576
+ end
577
+
578
+ def self.command_lookup(aggregates)
579
+ each_command(aggregates).each_with_object({}) do |(owner, command), index|
580
+ index["#{owner}.#{command.hecks_name}"] = command
581
+ end
582
+ end
583
+
584
+ # A REFERENCE RING IS NOT A MODELLING CHOICE, IT IS A MISSING ONE
585
+ # — a DDD aggregate is a consistency boundary precisely because
586
+ # something outside it can only ever point IN, by id, never the
587
+ # other way. A caller must be able to reason about one aggregate
588
+ # alone ; a ring back to where it started means no aggregate in
589
+ # it is a boundary anyone can reason about without the rest of
590
+ # the ring, and the whole ring is really one aggregate wearing
591
+ # several names.
592
+ #
593
+ # Checked at the bluebook level, not inside `AggregateBuilder`
594
+ # itself, because seeing a cycle needs every end declared — an
595
+ # aggregate finishes building long before it can know whether
596
+ # some later aggregate in the same file points back at it.
597
+ #
598
+ # ACYCLIC WITHIN A CHAPTER (ADR 0025, "References") — widened
599
+ # from the direct pair (A -> B -> A) this used to catch alone to
600
+ # any ring, however long (A -> B -> C -> A), the same DFS
601
+ # coloring a reference graph needs for any cycle. A cross-chapter
602
+ # reference is UNREACHABLE here rather than unchecked:
603
+ # `Reference#resolve` is scoped to its own chapter by
604
+ # construction, so a target this chapter never declares is a
605
+ # dangling name, not an edge — `edges.key?` below is what keeps
606
+ # the walk from ever leaving this chapter's own aggregates.
607
+ # Self-reference stays legal (`parent.parent.name` for a
608
+ # hierarchy is real and safe) — excluded the same way the
609
+ # direct-pair check already excluded it.
610
+ def self.validate_no_bidirectional_references!(aggregates)
611
+ edges = aggregates.each_with_object({}) do |aggregate, index|
612
+ index[aggregate.hecks_name] = aggregate.reference_targets.uniq.reject { |target| target == aggregate.hecks_name }
613
+ end
614
+
615
+ cycle = find_reference_cycle(edges)
616
+ return unless cycle
617
+
618
+ ring = "#{cycle.join(' -> ')} -> #{cycle.first}"
619
+ raise Malformed,
620
+ "reference cycle: #{ring} — an aggregate points at another by id, and a " \
621
+ "ring back to where it started means no aggregate in it is a boundary " \
622
+ "anyone can reason about alone ; break the ring, or let one side be found " \
623
+ "through a query instead of a reference pointing back"
624
+ end
625
+
626
+ # Plain DFS with a visiting/done coloring, over the reference
627
+ # graph THIS chapter's own aggregates declare. Returns the ring
628
+ # itself (in the order it closes), or nil.
629
+ def self.find_reference_cycle(edges)
630
+ state = {}
631
+
632
+ edges.each_key do |start|
633
+ cycle = reference_cycle_from(start, edges, state, [])
634
+ return cycle if cycle
635
+ end
636
+
637
+ nil
638
+ end
639
+
640
+ def self.reference_cycle_from(node, edges, state, path)
641
+ return nil if state[node] == :done
642
+ return path[path.index(node)..] if state[node] == :visiting
643
+
644
+ state[node] = :visiting
645
+ path.push(node)
646
+
647
+ edges[node].each do |target|
648
+ next unless edges.key?(target) # a name this chapter never declares is dangling, not an edge
649
+
650
+ found = reference_cycle_from(target, edges, state, path)
651
+ return found if found
652
+ end
653
+
654
+ path.pop
655
+ state[node] = :done
656
+ nil
657
+ end
658
+
659
+ # THE OTHER HALF OF A HOP — AggregateBuilder#seal_query_field
660
+ # recognised the HEAD of a dotted where-field that names one of
661
+ # its own references and deferred it here, unable to check
662
+ # further: it cannot yet resolve what the reference points AT.
663
+ # This runs once every aggregate exists in one chapter, so it
664
+ # can.
665
+ #
666
+ # Only WHERE clauses ever reach here — a hop on ORDER BY is
667
+ # refused outright, immediately, back in seal_query_field
668
+ # itself (that answer never needed the target's shape).
669
+ #
670
+ # AN ENTITY'S OWN QUERIES DID reach `EntityBuilder#reference_to`
671
+ # (added after this comment first claimed otherwise — S9, ADR
672
+ # 0025) without ever reaching HERE: tier-1 sealing
673
+ # (`AggregateBuilder#query_surfaces`) already recognises a hop
674
+ # on an entity's own field and DEFERS it exactly like an
675
+ # aggregate's, but nothing ever walked entity queries at tier 2
676
+ # to check the deferral — a bad hop, or even a well-formed one,
677
+ # built silently and then matched nothing at runtime
678
+ # (`QueryInterpreter#entity_rows` reads an element's fields by
679
+ # literal hash key, never follows a reference). Refused outright
680
+ # here instead of taught to follow the hop for real: no corpus
681
+ # member needs an entity query to cross a reference yet, and a
682
+ # named refusal beats a runtime that resolves nothing while
683
+ # looking like it might.
684
+ def self.validate_query_hops!(bluebook)
685
+ bluebook.aggregates.each do |aggregate|
686
+ aggregate.queries.each do |query|
687
+ query.wheres.each do |clause|
688
+ next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
689
+
690
+ validate_hop_clause!(aggregate, query, clause)
691
+ end
692
+ end
693
+
694
+ aggregate.entities.each { |entity| refuse_entity_query_hops!(aggregate, entity) }
695
+ end
696
+ end
697
+
698
+ # The chapter-wide half of AggregateBuilder's local query-argument
699
+ # inference. A hop cannot resolve while its aggregate is still being
700
+ # built; here every Reference has an owner and target, so a symbolic
701
+ # comparison can inherit the type of the scalar it compares without a
702
+ # duplicate query-local declaration.
703
+ def self.infer_hop_query_arguments!(bluebook)
704
+ bluebook.aggregates.each do |aggregate|
705
+ aggregate.queries.each do |query|
706
+ query.wheres.each do |clause|
707
+ name = clause.value
708
+ next unless name.is_a?(Symbol)
709
+ next if query.attribute(name)
710
+ next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
711
+
712
+ plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes)
713
+ next if plan.refusal || plan.hops.empty?
714
+
715
+ target = plan.hops.last.target
716
+ head, *nested = plan.tail.to_s.split(".")
717
+ leaf = if nested.empty? && target.lifecycle&.field.to_s == head
718
+ Attribute.new(name: name, type: String)
719
+ else
720
+ root = target.attributes.find { |candidate| candidate.name.to_s == head }
721
+ found = root && QuerySpecification::FieldPath.leaf_attribute(root, nested) do |type|
722
+ target.value_object(type)
723
+ end
724
+ found && Attribute.new(name: name, type: found.type, list: found.list?)
725
+ end
726
+ next unless leaf
727
+
728
+ query.attributes << leaf
729
+ end
730
+ end
731
+ end
732
+ end
733
+
734
+ def self.refuse_entity_query_hops!(aggregate, entity)
735
+ entity.queries.each do |query|
736
+ query.wheres.each do |clause|
737
+ next unless QuerySpecification::HopPath.hop_head?(clause.field, entity.attributes)
738
+
739
+ raise Malformed,
740
+ "#{aggregate.hecks_name}::#{entity.hecks_name}.#{query.hecks_name} asks about " \
741
+ "#{clause.field}, which hops through #{entity.hecks_name}'s own reference — " \
742
+ "an entity query does not follow a hop the way an aggregate's own does; ask " \
743
+ "through the aggregate's own query instead, or open the target directly"
744
+ end
745
+ end
746
+ end
747
+
748
+ def self.validate_hop_clause!(aggregate, query, clause)
749
+ plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes)
750
+
751
+ case plan.refusal
752
+ when :unresolvable
753
+ # HopPath.plan pushes even an unresolved hop onto `hops`
754
+ # before reporting this, specifically so `target_name` —
755
+ # real, known at declaration, independent of whether
756
+ # `resolve` succeeded — is always here to name.
757
+ raise Malformed,
758
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
759
+ "which hops to #{plan.hops.last.target_name}, which this chapter never " \
760
+ "declares — a hop into an aggregate this chapter cannot see resolves to " \
761
+ "nothing, and a where that resolves to nothing matches nothing and " \
762
+ "refuses nothing"
763
+ when :too_deep
764
+ raise Malformed,
765
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
766
+ "whose hop chain reaches #{QuerySpecification::HopPath::MAX_HOPS} " \
767
+ "references deep without landing — a chain this long is refused as a " \
768
+ "likely mistake, not a structural limit"
769
+ end
770
+
771
+ target = plan.hops.last.target
772
+ validate_hop_tail!(aggregate, query, clause, target, plan.tail)
773
+ end
774
+
775
+ # The same three-way answer seal_query_field gives for its OWN
776
+ # aggregate's fields — landing on a real scalar (fine), landing
777
+ # on a value object (refused by name), or naming nothing at all
778
+ # (refused by name) — asked instead of the hop's TARGET aggregate,
779
+ # since that is whose shape the tail actually has to answer for.
780
+ def self.validate_hop_tail!(aggregate, query, clause, target, tail)
781
+ name, *nested = tail.to_s.split(".")
782
+ attribute = target.attributes.find { |candidate| candidate.name.to_s == name }
783
+ return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if
784
+ nested.empty? && (attribute || target.lifecycle&.field.to_s == name)
785
+ return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if
786
+ nested.any? && attribute &&
787
+ QuerySpecification::FieldPath.scalar_leaf?(attribute, nested) { |type| target.value_object(type) }
788
+
789
+ if nested.any? && attribute &&
790
+ !QuerySpecification::FieldPath.leaf_attribute(attribute, nested) { |type| target.value_object(type) }.nil?
791
+ raise Malformed,
792
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
793
+ "which hops to #{target.hecks_name} and then asks about #{tail}, which " \
794
+ "lands on a value object, not a scalar — a dotted query path ends on a " \
795
+ "scalar member, or the engines answer it differently"
796
+ end
797
+
798
+ raise Malformed,
799
+ "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \
800
+ "which hops to #{target.hecks_name} and then asks about #{tail}, which " \
801
+ "#{target.hecks_name} never declares — a query over a field that does " \
802
+ "not exist matches nothing and refuses nothing"
803
+ end
804
+
805
+ # A WHERE hop with an ordered comparator is legitimate ("client
806
+ # whose balance > 500") — AggregateBuilder#seal_ordered_comparator
807
+ # already deferred this exact check for the same reason every
808
+ # other hop check is deferred, and this is where it gets asked,
809
+ # against the hop's TARGET instead of the querying aggregate.
810
+ def self.validate_hop_comparator!(aggregate, query, clause, target, attribute, nested)
811
+ return unless AggregateBuilder::ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym)
812
+ return if attribute &&
813
+ QuerySpecification::FieldPath.numeric?(attribute, nested) { |type| target.value_object(type) }
814
+
815
+ held = attribute ? "holds no number" : "is the lifecycle field, which holds text"
816
+ raise Malformed,
817
+ "#{aggregate.hecks_name}.#{query.hecks_name} compares #{clause.field} with " \
818
+ "#{clause.op} after hopping to #{target.hecks_name}, but the field it lands " \
819
+ "on #{held} — an ordered comparison needs a numeric field, and over " \
820
+ "anything else the adapters answer differently or not at all"
821
+ end
822
+
823
+ # THE TARGET HALF of `projects` validation (S12, ADR 0025) —
824
+ # `AggregateBuilder#seal_projected_fields` already checked the
825
+ # LOCAL half at declare time (the reference names a real
826
+ # `reference_to` on THIS aggregate); this checks the reference
827
+ # actually resolves to a real aggregate in this chapter, and
828
+ # that aggregate really declares `remote_field` as a scalar.
829
+ #
830
+ # Reuses `QuerySpecification::HopPath` rather than re-deriving
831
+ # hop resolution a second way — `"reference/remote_field"` is
832
+ # the same single-hop shape a query's own `/`-spelled hop
833
+ # resolves, even though `projects`'s own DSL spelling is dotted
834
+ # (`from: :"customer.status"`): two constructs, two spellings,
835
+ # one resolution primitive. A single hop can never reach
836
+ # HopPath::MAX_HOPS, so :too_deep is structurally unreachable
837
+ # here and is not special-cased.
838
+ def self.validate_projected_fields!(bluebook)
839
+ bluebook.aggregates.each do |aggregate|
840
+ aggregate.projected_fields.each { |field| validate_projected_field!(aggregate, field) }
841
+ end
842
+ end
843
+
844
+ def self.validate_projected_field!(aggregate, field)
845
+ plan = QuerySpecification::HopPath.plan("#{field.reference}/#{field.remote_field}", aggregate.attributes)
846
+
847
+ if plan.refusal == :unresolvable
848
+ raise Malformed,
849
+ "#{aggregate.hecks_name}.projects :#{field.name} reads through :#{field.reference}, " \
850
+ "which hops to #{plan.hops.last.target_name}, which this chapter never declares — " \
851
+ "a projection through an aggregate this chapter cannot see resolves to nothing"
852
+ end
853
+
854
+ target = plan.hops.last.target
855
+ remote_attribute = target.attributes.find { |candidate| candidate.name.to_s == plan.tail }
856
+
857
+ # THE WORKED EXAMPLE ITSELF (ADR 0025) reads through a
858
+ # LIFECYCLE field — banking's Customer.status is `lifecycle
859
+ # :status`, never a plain `attribute` — the same fallback
860
+ # validate_hop_tail! already gives a query's own hop tail. A
861
+ # lifecycle field is always a plain string by construction ;
862
+ # nothing further to check once it matches by name.
863
+ return if remote_attribute.nil? && target.lifecycle&.field.to_s == plan.tail
864
+
865
+ unless remote_attribute
866
+ raise Malformed,
867
+ "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \
868
+ "#{plan.tail.inspect}, which #{target.hecks_name} never declares"
869
+ end
870
+
871
+ return if projectable_scalar?(target, remote_attribute)
872
+
873
+ raise Malformed,
874
+ "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \
875
+ "#{plan.tail.inspect}, which is not a scalar — a projected field copies a single " \
876
+ "value, never a reference, a value object, or a list"
877
+ end
878
+
879
+ def self.projectable_scalar?(target, attribute)
880
+ !attribute.list? && !attribute.reference? && target.value_object(attribute.type).nil?
881
+ end
882
+
883
+ # `correlates_by` NAMES A SCALAR, NOW CHECKED RATHER THAN TRUSTED.
884
+ #
885
+ # ProcessManagerBuilder#validate! already refuses a bare, undotted
886
+ # spelling — a SYNTACTIC guarantee that the declaration cannot leave
887
+ # the question open. It cannot go further: a process manager is built
888
+ # in isolation, before this chapter's aggregates exist to check
889
+ # against. Here, with the whole document assembled, the dotted path
890
+ # is walked for real — against whichever command actually emits an
891
+ # event this process manager reacts to — so a path that still lands
892
+ # on a value object is refused before the runtime ever has to decide
893
+ # what a non-scalar correlation key even means: a saga keys off this
894
+ # value directly, and a value object carries no guaranteed-stable
895
+ # identity to key on the way a scalar does.
896
+ #
897
+ # A command that does not declare the path's first segment at all is
898
+ # silently skipped, not refused — correlation has two other fallback
899
+ # tiers below the payload dig (a correlation stamp, then the emitting
900
+ # aggregate's own reference key; saga_interpreter/correlation.rb), so
901
+ # an absent field is not this check's business. Only a field that
902
+ # resolves, and resolves to something other than a scalar, is.
903
+ def self.validate_correlation_keys!(process_managers, aggregates)
904
+ process_managers.each do |pm|
905
+ next unless pm.correlates_by
906
+
907
+ reason = correlation_key_violation(pm, aggregates)
908
+ next unless reason
909
+
910
+ raise ProcessManagerBuilder::InvalidProcessManager,
911
+ "#{pm.name} correlates_by #{pm.correlates_by.inspect}, but #{reason}"
912
+ end
913
+ end
914
+
915
+ def self.correlation_key_violation(pm, aggregates)
916
+ head, *rest = pm.correlates_by.to_s.split(".")
917
+ events = reacted_events(pm)
918
+
919
+ emitting_commands(events, aggregates).each do |owner, command|
920
+ attribute = command.attributes.find { |a| a.name == head.to_sym }
921
+ next unless attribute
922
+
923
+ reason = list_or_scalar_violation(owner, attribute, rest)
924
+ return reason if reason
925
+ end
926
+
927
+ nil
928
+ end
929
+
930
+ def self.reacted_events(pm)
931
+ ([pm.starts_on, pm.ends_on] + pm.handlers.map(&:event_type))
932
+ .compact
933
+ .reject { |event| event == ProcessManager::REFUSED }
934
+ .map { |event| event.to_s.split("::").last }
935
+ .uniq
936
+ end
937
+
938
+ def self.emitting_commands(events, aggregates)
939
+ aggregates.flat_map do |aggregate|
940
+ commands = aggregate.commands + aggregate.entities.flat_map(&:commands)
941
+ commands.select { |command| (command.emits.map(&:to_s) & events).any? }
942
+ .map { |command| [aggregate, command] }
943
+ end
944
+ end
945
+
946
+ def self.list_or_scalar_violation(owner, attribute, segments)
947
+ return "#{attribute.name} is a list — a correlation key must name one instance's own field, " \
948
+ "not a whole collection" if attribute.list?
949
+
950
+ walk_scalar(owner, attribute.type.to_s, segments)
951
+ end
952
+
953
+ # Walks the remaining dotted segments through nested value objects.
954
+ # `type_name` starts as the head attribute's own declared type ; each
955
+ # step either bottoms out at a real scalar (nil — no violation) or
956
+ # names why it cannot: still a value object with no more path left,
957
+ # a value object this domain never declared, a field that value
958
+ # object does not have, or a segment left over after already
959
+ # reaching a scalar.
960
+ def self.walk_scalar(owner, type_name, segments)
961
+ if segments.empty?
962
+ return nil if Attribute::PRIMITIVES.include?(type_name)
963
+
964
+ return "#{type_name} is a value object, not a scalar — name one of its own fields, " \
965
+ "e.g. #{type_name.downcase}.value"
966
+ end
967
+
968
+ return "#{type_name} is already a scalar — #{segments.join('.')} has nothing left to reach" if Attribute::PRIMITIVES.include?(type_name)
969
+
970
+ shape = owner.value_object(type_name)
971
+ return "#{type_name} is not a value object this domain declares" unless shape
972
+
973
+ segment, *rest = segments
974
+ attribute = shape.attributes.find { |a| a.name == segment.to_sym }
975
+ return "#{type_name} has no field #{segment.inspect}" unless attribute
976
+ return "#{type_name}.#{segment} is a list — a correlation key must name one instance's own field, " \
977
+ "not a whole collection" if attribute.list?
978
+
979
+ walk_scalar(owner, attribute.type.to_s, rest)
980
+ end
981
+
982
+ # A CHAPTER MAY BE DECLARED IN SEVERAL FILES, meant to merge into ONE
983
+ # domain — `lib/hecks/language/bluebook/*.bluebook` all open
984
+ # `Hecks.bluebook "Bluebook" do ... end`. Each `Hecks.bluebook` call used to
985
+ # mint a fresh builder, so a second file with the same chapter name
986
+ # silently replaced the first's aggregates instead of adding to them.
987
+ #
988
+ # The registry now holds the builder OPEN across calls : the first file
989
+ # for a name creates it, every later file for the same name reuses the
990
+ # same instance, so `@aggregates`/`@read_models` accumulate. `#build` is
991
+ # safe to call once per file on the same builder — it constructs a fresh
992
+ # `Bluebook` from whatever is currently held and re-`Namespace.install`s
993
+ # over the previous one, so the LAST file's call leaves every aggregate
994
+ # seen so far reachable, and each call's IR is a strict superset of the
995
+ # one before. `Registry#add_bluebook` still simply stores by name — with
996
+ # this in place, "last write wins" is the cumulative, correct write.
997
+ def self.build(name, version: nil, &block)
998
+ registry = Hecks.current_registry
999
+ builder = registry ? registry.bluebook_builder(name) { new(name, version: version) } : new(name, version: version)
1000
+ builder.__send__(:adopt_version, version)
1001
+ # A bare constant in a bluebook — `attribute :name, PizzaName` — is a NAME,
1002
+ # not a reference to something Ruby has heard of. `const_missing` hands
1003
+ # over a `ConstShim::ScopedConstant` (S0b, const_shim.rb's own comment),
1004
+ # and that is still the whole answer for a bare name: `Attribute` spells
1005
+ # it with `to_s`, so the `TypeName` wrapper this used to build existed
1006
+ # only long enough to be stringified. The concept still has a home — the
1007
+ # language declares `value_object "TypeName"` — it just needed no Ruby
1008
+ # class of its own. A Module rather than a Symbol is what also lets
1009
+ # `Account::Debit`/`admits: Account::LedgerDirection` answer their OWN
1010
+ # `::` — a plain Symbol cannot.
1011
+ resolver = ->(const) { ConstShim::ScopedConstant.for(const) }
1012
+ ConstShim.with(resolver) { builder.instance_eval(&block) } if block
1013
+ builder.build
1014
+ end
1015
+ end
1016
+ end
1017
+ end
1018
+ end