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,906 @@
1
+ require_relative "word_gate"
2
+ module Hecks
3
+ module Bluebook
4
+ module DSL
5
+ class AggregateBuilder
6
+ GRAMMAR_CONTEXT = "Aggregate"
7
+
8
+ include AttributeCollector
9
+ include IdentityDeclaration
10
+ include RuleReference
11
+ include WordGate
12
+
13
+ def initialize(name, chapter_named_givens: {}, chapter_pending_givens: [])
14
+ @name = name
15
+ @value_objects = []
16
+ @commands = []
17
+ @invariants = []
18
+ @named_givens = {}
19
+ @projected_fields = []
20
+ @identity_paths = []
21
+ @entities = []
22
+ @queries = []
23
+ @policies = []
24
+ @reference_targets = []
25
+ # THE ROOT of the cross-entity given pool — see `#entity`'s own
26
+ # comment. ONE hash for the whole aggregate, threaded unchanged
27
+ # into every piece nested under it, however deep.
28
+ @entity_named_givens = {}
29
+ # ONE LEVEL WIDER STILL — the CHAPTER's own pool, threaded in
30
+ # from `BluebookBuilder#aggregate`, shared with every OTHER
31
+ # aggregate the same chapter builds. See `#given`'s own
32
+ # comment for what this closes.
33
+ @chapter_named_givens = chapter_named_givens
34
+ # A CHAPTER MAY BE SPLIT ACROSS FILES — threaded in the SAME
35
+ # way as `@chapter_named_givens`, one Array shared chapter-wide.
36
+ # See `#pending_chapter_given`'s own comment for what queues
37
+ # here and `BluebookBuilder#resolve_pending_chapter_givens!`
38
+ # for where it drains.
39
+ @chapter_pending_givens = chapter_pending_givens
40
+ # DEFERRED CONSTRUCTION — `entity`/`command`/`query` push a
41
+ # pending descriptor here instead of building immediately; see
42
+ # `#drain_pending!`'s own comment for why.
43
+ @pending_entities = []
44
+ @pending_commands = []
45
+ @pending_queries = []
46
+ end
47
+
48
+ def description(value)
49
+ # moved to the language: Description invariant, on Root.Declare
50
+
51
+ @description = value
52
+ end
53
+
54
+ # ORIGIN, not runtime identity — a concept adopted from a canonical
55
+ # source (§28) names where it came from without that fact ever
56
+ # touching `hecks_fqn`/dispatch. Captured raw, the same way
57
+ # `attribute ..., default: { value: "small" }` captures a literal
58
+ # Hash untouched — no re-parsing, no structure imposed beyond
59
+ # "whatever the author wrote."
60
+ # RENAMED FROM `provenance`/`projects`/`lifecycle`/`entity`/
61
+ # `query`/`policy`/`command` (all below) — item #13's full
62
+ # metaprogrammed dispatch (slice 4c). All bootstrap-reachable
63
+ # (used throughout the core/attached chapters), all in
64
+ # GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.
65
+ def provenance_impl(from:)
66
+ @provenance = from
67
+ end
68
+
69
+ # `optional:` — matching `CommandBuilder#reference_to`'s own
70
+ # signature, which already had it; this one never forwarded it
71
+ # to `attribute_impl()` even though `attribute_impl()` itself
72
+ # already accepts it. A real gap: an aggregate that can point at
73
+ # ONE OF several targets (Item's own `personal_list_id`/
74
+ # `camping_list_id`, never both) needs each reference optional
75
+ # on the aggregate's own persisted schema, not just as a
76
+ # command's input.
77
+ # RENAMED FROM `reference_to` — item #13's full metaprogrammed
78
+ # dispatch (slice 4b). Bootstrap-reachable (every core/attached
79
+ # grammar chapter uses reference_to to describe itself), so also
80
+ # named in GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.
81
+ def reference_to_impl(type, as: nil, optional: false)
82
+ target = Naming.demodulise(type)
83
+ @reference_targets << target
84
+ relationship_attribute(target, :reference_to,
85
+ as || default_reference_name(target), optional: optional)
86
+ end
87
+
88
+ # A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
89
+ # ADR 0025 — "Consistency across aggregate boundaries"). A
90
+ # `given`/`ensures`/`invariant` used to reach through a
91
+ # `reference_to` at RULE-EVALUATION TIME (`References#
92
+ # dereference`, a live query against another aggregate's own
93
+ # repository, unbounded and inconsistent with the "a rule reads
94
+ # only this record" model everywhere else) — `projects` is what
95
+ # replaces that: `projects :customer_status, from: :"customer.
96
+ # status"` declares that THIS aggregate holds its own copy of
97
+ # `Customer`'s own `:status`, kept fresh by a REBUILD SWEEP
98
+ # (`Runtime::ProjectionRebuild`) rather than read live. A rule
99
+ # then reads `customer_status` the same way it reads any other
100
+ # local field — no dot, no reference walk.
101
+ #
102
+ # `from:` NAMES THE LOCAL REFERENCE, not the target aggregate —
103
+ # `customer`, the attribute THIS aggregate's own `reference_to
104
+ # Customer` already minted, not `Customer` the type — so two
105
+ # references to the same aggregate (aliased differently) can
106
+ # each carry their own projection without ambiguity. The TARGET
107
+ # field's own existence cannot be checked here: the target
108
+ # aggregate does not exist yet while THIS one is still being
109
+ # declared (the same reason a query's own hop tail is checked
110
+ # by `BluebookBuilder#validate_query_hops!`, once every
111
+ # aggregate in the chapter is real, not by `AggregateBuilder`
112
+ # itself) — `validate_projected_fields!` is where that half
113
+ # happens.
114
+ def projects_impl(name, from:)
115
+ reference, _, remote_field = from.to_s.rpartition(".")
116
+
117
+ if reference.empty? || remote_field.empty?
118
+ raise Malformed,
119
+ "#{@name}.projects :#{name} names #{from.inspect}, which is not " \
120
+ "reference.field — say which reference and which field on it, e.g. " \
121
+ "from: :\"customer.status\""
122
+ end
123
+
124
+ @projected_fields << ProjectedField.new(name: name.to_sym, reference: reference.to_sym,
125
+ remote_field: remote_field.to_sym)
126
+ end
127
+
128
+ # `has_many`/`has_one`/`belongs_to` were LEGACY (ADR 0025,
129
+ # "References") — sugar over `reference_to` that collapsed to an
130
+ # anonymous reference and, for `has_many`, LIED (singularised its
131
+ # target and minted one scalar, so `film.backers` read `nil` and
132
+ # never `[]`). Wave 6 (identity-and-relationships arc) un-deprecates
133
+ # all three for real: a relationship word now retains the author's
134
+ # domain concept in IR — still stored as one or more target
135
+ # identities, but no longer collapsed to a bare `reference_to`
136
+ # during assembly. `MetaValidator.shadow_parsing?` still routes to
137
+ # `legacy_has_many`/`legacy_has_one` so frozen era text written
138
+ # under the OLD (lying/collapsing) meaning still parses the way it
139
+ # did when it was written — real, if rare corpus: "Combined corpus
140
+ # uses: one."
141
+ #
142
+ # RENAMED FROM `has_many`/`has_one`/`belongs_to` — item #13's full
143
+ # metaprogrammed dispatch (slice 4). Each Keyword row's own
144
+ # `calls:` names the matching `_impl`; not bootstrap-reachable
145
+ # (no core/attached chapter uses one of these to describe itself),
146
+ # so no BOOTSTRAP_CALLS_FALLBACK entry is needed, unlike
147
+ # `attribute`/`role`.
148
+ def has_many_impl(type, as: nil, **legacy_options)
149
+ if MetaValidator.shadow_parsing?
150
+ return legacy_has_many(type, as: as, optional: legacy_options.fetch(:optional, false))
151
+ end
152
+
153
+ unless legacy_options.empty?
154
+ raise Malformed, "#{@name}.has_many takes no #{legacy_options.keys.first}: — an empty list already means none"
155
+ end
156
+
157
+ plural = Naming.demodulise(type)
158
+ target = Naming.singularize(plural)
159
+ @reference_targets << target
160
+ relationship_attribute(target, :has_many, as || Naming.snake(plural).to_sym,
161
+ list: true)
162
+ end
163
+
164
+ def has_one_impl(type, as: nil, optional: false)
165
+ return legacy_has_one(type, as: as, optional: optional) if MetaValidator.shadow_parsing?
166
+
167
+ target = Naming.demodulise(type)
168
+ @reference_targets << target
169
+ relationship_attribute(target, :has_one, as || Naming.snake(target).to_sym,
170
+ optional: optional)
171
+ end
172
+
173
+ def belongs_to_impl(type, as: nil, optional: false)
174
+ return legacy_has_one(type, as: as, optional: optional) if MetaValidator.shadow_parsing?
175
+
176
+ target = Naming.demodulise(type)
177
+ @reference_targets << target
178
+ relationship_attribute(target, :belongs_to, as || Naming.snake(target).to_sym,
179
+ optional: optional)
180
+ end
181
+
182
+ def lifecycle_impl(field, default:, &block)
183
+ @lifecycle = LifecycleBuilder.build(field, default: default, &block)
184
+ end
185
+
186
+ # A piece is declared IN this aggregate — its owner is stamped by
187
+ # `Aggregate#initialize`, once the aggregate exists. Its own
188
+ # commands were given the piece as their owner when it was declared,
189
+ # so the chain closes as chapter -> aggregate -> entity -> command.
190
+ # NOT built here — see `#drain_pending!`'s own comment for why
191
+ # this only queues a descriptor.
192
+ #
193
+ # A PRECONDITION SHARED ACROSS SIBLING PIECES, DECLARED ONCE — one
194
+ # level wider than round 4's own `EntityBuilder#given` (shared
195
+ # across ONE piece's own commands): `@entity_named_givens` is the
196
+ # SAME hash threaded into EVERY piece this aggregate builds, so a
197
+ # piece's own entity-level `given(desc) { block }` write-throughs
198
+ # into it, and any OTHER piece's own command can reference it back
199
+ # bare, the identical description/canonical, evaluated in ITS OWN
200
+ # `parent`-relative context. Real, live corpus this closes:
201
+ # `SafeDepositBox`'s `Visit`/`KeyIssuance` — two DIFFERENT pieces
202
+ # under one head, each independently typing `given("customer is
203
+ # active") { parent.customer.status == "active" }` byte for byte,
204
+ # which neither the aggregate's OWN "customer is active" (a
205
+ # DIFFERENT canonical — bare `customer.status`, not
206
+ # `parent.customer.status`, wrong scope for a piece's own command
207
+ # to evaluate) nor round 4's single-piece `given` could reach.
208
+ def entity_impl(name, &block)
209
+ @pending_entities << [name, block]
210
+ end
211
+
212
+ def query_impl(name, &block)
213
+ @pending_queries << [name, block]
214
+ end
215
+
216
+ def policy_impl(name, &block)
217
+ reaction = PolicyBuilder.build(name, &block)
218
+ reaction.aggregate = @name
219
+ @policies << reaction
220
+ end
221
+
222
+ # `builder.closed_sets` TOO, not only `builder.build` — a REAL,
223
+ # previously-unreachable gap this exact fix exposed: a
224
+ # value_object's own INLINE `attribute :x, one_of(...)` (now legal
225
+ # — S3, ADR 0025 removed the wrong-arity collision that used to
226
+ # make this crash before it could ever matter) synthesises its own
227
+ # anonymous value object via the SAME `AttributeCollector#closed_
228
+ # sets` mechanism an aggregate's own attributes already use — and
229
+ # nothing installed it anywhere. `Box.attributes` said `size:
230
+ # "Size"` while no "Size" value object existed in the whole
231
+ # domain: a dangling type name, not a working closed set. Flattened
232
+ # into THIS aggregate's own `@value_objects`, the identical move
233
+ # `@value_objects + closed_sets` already makes for the aggregate's
234
+ # own direct attributes (see this file's other 5 call sites).
235
+ def value_object(name, &block)
236
+ builder = ValueObjectBuilder.new(name, owner_value_objects: @value_objects + closed_sets)
237
+ builder.instance_eval(&block) if block
238
+ @value_objects << builder.build
239
+ @value_objects.concat(builder.closed_sets)
240
+ end
241
+
242
+ # `from:` — LIFECYCLE STATE BECOMES A COMMAND GUARD (S10, ADR
243
+ # 0025) — `command "Debit", from: "open"` replaces `given
244
+ # ("account is open") { status == "open" }`, written 35 times
245
+ # in two wordings across the corpus. Checked against THIS
246
+ # aggregate's own lifecycle field (`Admissibility#enforce_
247
+ # lifecycle_guard`) — never a target state, never a transition:
248
+ # the lifecycle already declares which states exist, so naming
249
+ # the legal ones is checkable against it, where a free-text
250
+ # given could drift out of sync with the state machine and did.
251
+ def command_impl(name, from: nil, &block)
252
+ # The verb is declared ON this aggregate — the owner `acts_on` answers
253
+ # with — stamped by `Aggregate#initialize` once the aggregate
254
+ # exists. An ENTITY's commands take the entity as their owner instead,
255
+ # at the entity's own declaration. NOT built here — see
256
+ # `#drain_pending!`'s own comment for why this only queues a
257
+ # descriptor.
258
+ @pending_commands << [name, from, block]
259
+ end
260
+
261
+ # A PRECONDITION SHARED ACROSS COMMANDS, DECLARED ONCE (S10, ADR
262
+ # 0025) — an aggregate-level `given`, block required, stored by
263
+ # its own description rather than appended anywhere: a command
264
+ # names it back (`given("customer is active")`, no block of its
265
+ # own) rather than re-typing the predicate, so there is one
266
+ # description and therefore one refusal message no matter which
267
+ # command a caller hits. DECLARE BEFORE THE COMMANDS THAT
268
+ # REFERENCE IT — resolution happens at the referencing command's
269
+ # OWN build time (`CommandBuilder#given`), against whatever this
270
+ # aggregate has declared SO FAR, the one ordering constraint this
271
+ # word carries that `identified_by`/`attribute` do not.
272
+ # BARE — NO BLOCK — REFERENCES a SIBLING AGGREGATE's own
273
+ # already-declared precondition, one level wider than the
274
+ # existing bare-command-references-its-own-aggregate shape
275
+ # (`CommandBuilder#reference_named_given`): `SafeDepositBox`/
276
+ # `OnboardingCase` both name back `Account`'s own "customer is
277
+ # active" rather than retyping `customer.status == "active"` a
278
+ # third and fourth time. Resolved against `@chapter_named_givens`
279
+ # — see `BluebookBuilder#aggregate`'s own comment for how that
280
+ # pool is threaded, and `docs/implemented/resolution-rules/chapter-given.md`
281
+ # for the full algorithm and its known limitations (a bare
282
+ # reference trusts its own author to have verified the SAME
283
+ # canonical predicate applies — this mechanism does not, and
284
+ # cannot, check that itself; see that doc for which real corpus
285
+ # cases do and do not qualify).
286
+ #
287
+ # `declared_by:` DISAMBIGUATES the same description meaning TWO
288
+ # genuinely different predicates chapter-wide — real, live:
289
+ # `Account`'s own "customer is active" reads bare
290
+ # `customer.status` (a DIRECT `reference_to Customer`); `ATMCard`'s
291
+ # own (shared onward with `CardPayment`/`ExternalTransfer`/
292
+ # `ScheduledPayment`/`Statement`) reads `account.customer.status`
293
+ # (reached THROUGH `Account`) — the identical business fact, a
294
+ # genuinely different runtime path, correctly kept as the SAME
295
+ # domain wording rather than invented a second spelling for "the
296
+ # same idea, one more hop away" (S10, ADR 0025's own "one idea,
297
+ # one spelling"). Omit it when the description is unambiguous
298
+ # chapter-wide (the common case, and the ONLY case this took
299
+ # before this parameter existed) — required only once a SECOND,
300
+ # textually-different canonical registers under the same
301
+ # description; see `reference_named_chapter_given`'s own
302
+ # ambiguity error for how that surfaces.
303
+ # RENAMED FROM `given` — item #13's full metaprogrammed dispatch
304
+ # (slice 4b), same reasoning as reference_to_impl above:
305
+ # bootstrap-reachable, in BOOTSTRAP_CALLS_FALLBACK.
306
+ def given_impl(description, declared_by: nil, &predicate)
307
+ return reference_named_chapter_given(description, declared_by: declared_by) unless predicate
308
+
309
+ named = build_rule(Given, description, predicate, owner_name: @name, word: "given",
310
+ extraction_failure: "its source could not be read, so no other runtime could ever evaluate it")
311
+ @named_givens[description] = named
312
+ # WRITE-THROUGH, first-declared-wins PER OWNER — keyed by
313
+ # [description, this aggregate's own name], not description
314
+ # alone: two DIFFERENT aggregates independently declaring the
315
+ # SAME description are two DISTINCT candidates a later bare
316
+ # reference chooses between (via `declared_by:` once there is
317
+ # more than one), never silently merged into one slot the way
318
+ # a bare description-only key would.
319
+ @chapter_named_givens[description] ||= {}
320
+ @chapter_named_givens[description][@name] ||= named
321
+ end
322
+
323
+ private
324
+
325
+ # PRIMITIVE 2 (RuleReference#resolve_owner_keyed) — see that
326
+ # method's own comment for the pool shape; the three branches
327
+ # below (exact owner / unambiguous single candidate / ambiguous)
328
+ # are this construct's OWN refusal wording, not shared, since
329
+ # `declared_by:` only exists here so far. UNRESOLVED (no
330
+ # candidate yet, or `declared_by:` naming an aggregate that
331
+ # hasn't declared it yet) is no longer a fourth branch that
332
+ # raises HERE — see `#pending_chapter_given`, below, for why:
333
+ # a chapter split across files can genuinely reference a
334
+ # precondition a LATER file declares, and "not found among
335
+ # what's loaded so far" cannot tell that apart from "genuinely
336
+ # never declared" until every file has.
337
+ def reference_named_chapter_given(description, declared_by:)
338
+ verify_resolves_via!("given", "Aggregate", "owner_keyed")
339
+ candidates = resolve_owner_keyed(@chapter_named_givens, description)
340
+
341
+ named =
342
+ if declared_by
343
+ owner = Naming.demodulise(declared_by)
344
+ candidates[owner] || pending_chapter_given(description, declared_by: owner)
345
+ elsif candidates.size == 1
346
+ candidates.values.first
347
+ elsif candidates.empty?
348
+ pending_chapter_given(description, declared_by: nil)
349
+ else
350
+ raise(Malformed,
351
+ "#{@name}'s given #{description.inspect} is ambiguous in this chapter — " \
352
+ "#{candidates.keys.join(', ')} each declare a DIFFERENT predicate under " \
353
+ "this same description; name which one with declared_by: (e.g. " \
354
+ "given(#{description.inspect}, declared_by: #{candidates.keys.first}))")
355
+ end
356
+
357
+ @named_givens[description] = named
358
+ end
359
+
360
+ # A CHAPTER MAY BE SPLIT ACROSS FILES — the SAME reason a query
361
+ # hop's own cross-file target, a correlation key's own emitting
362
+ # command, and an event's own declared shape are all resolved
363
+ # once the whole chapter is assembled rather than refused the
364
+ # moment one file's own bare reference outruns what's loaded so
365
+ # far (`BluebookBuilder.validate_assembled!`'s own comment).
366
+ #
367
+ # Unlike those, though, a chapter-given's resolved value is not
368
+ # a pass/fail check on an already-built IR — it IS part of the
369
+ # referencing aggregate's own IR (`preconditions:` below), built
370
+ # and handed off the moment THIS aggregate's own file finishes
371
+ # loading, long before a later file might declare the real
372
+ # thing. So this hands back a PLACEHOLDER `Given` — embedded
373
+ # exactly where the resolved one would be, by Ruby object
374
+ # reference, in this aggregate's own `preconditions` AND in any
375
+ # command in this SAME aggregate that separately bare-references
376
+ # the same description (`CommandBuilder#given`'s own hash-chain
377
+ # read of this aggregate's `@named_givens`, the identical key) —
378
+ # and queues the request in `@chapter_pending_givens`.
379
+ # `BluebookBuilder#resolve_pending_chapter_givens!` MUTATES this
380
+ # exact object in place, once every file has loaded, so every
381
+ # existing reference to it (there is only ever the one object,
382
+ # never a copy) sees the resolved fields simultaneously. Safe
383
+ # because every real reader of a `Given` — refusal wording at
384
+ # dispatch, `Aggregate`'s own lazy `-> { preconditions.map { ... } }`
385
+ # IR accessor, docs — runs strictly after boot completes, never
386
+ # mid-load; `judge_deferred!` resolves every pending chapter-given
387
+ # before anything else touches this chapter's assembled IR.
388
+ def pending_chapter_given(description, declared_by:)
389
+ placeholder = Given.new(description: description, canonical: nil, predicate: nil)
390
+ @chapter_pending_givens << { aggregate: @name, description: description,
391
+ declared_by: declared_by, placeholder: placeholder }
392
+ placeholder
393
+ end
394
+
395
+ public
396
+
397
+ # THE AGGREGATE BOUNDARY IS WHAT AN INVARIANT DEFINES (S10, ADR
398
+ # 0025 — "Rules") — checked after every command, before save,
399
+ # the same way a value object's already is
400
+ # (`ValueObjectBuilder#invariant`, whose own shape this mirrors
401
+ # exactly). Today `invariant` lived only inside `value_object`;
402
+ # an aggregate-level rule had nowhere to live, so "the balance
403
+ # never goes negative" was three different `given`/`ensures`
404
+ # texts across banking's six balance-moving commands, and the
405
+ # four that only increase it said nothing at all — completeness
406
+ # depended on someone noticing which commands could decrease it.
407
+ # RENAMED FROM `invariant` — item #13's full metaprogrammed
408
+ # dispatch (slice 4b), same reasoning as given_impl above.
409
+ def invariant_impl(description, &predicate)
410
+ @invariants << build_rule(Invariant, description, predicate, owner_name: @name, word: "invariant",
411
+ extraction_failure: "it would be a rule the IR cannot carry")
412
+ end
413
+
414
+ def build
415
+ drain_pending!
416
+ resolve_pending_identity!
417
+ seal_mutation_targets
418
+ seal_query_targets
419
+ seal_defaults
420
+ seal_lifecycle_guards
421
+ seal_projected_fields
422
+
423
+ ir = Aggregate.new(
424
+ name: @name,
425
+ description: @description,
426
+ attributes: attributes,
427
+ value_objects: @value_objects + closed_sets,
428
+ commands: @commands,
429
+ invariants: @invariants,
430
+ preconditions: @named_givens.values,
431
+ projected_fields: @projected_fields,
432
+ identified_by: @identity_paths,
433
+ lifecycle: @lifecycle,
434
+ entities: @entities,
435
+ queries: @queries,
436
+ policies: @policies,
437
+ reference_targets: @reference_targets + entity_reference_targets,
438
+ provenance: @provenance
439
+ )
440
+
441
+ # After the IR exists, on purpose : a reference is declared IN the
442
+ # aggregate, and the aggregate the IR graph knows is `ir`, not the
443
+ # builder.
444
+ stamp_references(ir)
445
+ ir
446
+ end
447
+
448
+ def self.build(name, chapter_named_givens: {}, chapter_pending_givens: [], &block)
449
+ builder = new(name, chapter_named_givens: chapter_named_givens, chapter_pending_givens: chapter_pending_givens)
450
+ builder.instance_eval(&block) if block
451
+ builder.build
452
+ end
453
+
454
+ private
455
+
456
+ # DEFERRED CONSTRUCTION — `entity`/`command`/`query` used to build
457
+ # immediately, INLINE, the moment their own DSL line ran during
458
+ # `instance_eval` — meaning a command's own resolution (`sets
459
+ # :field` importing the owner's own attribute, `given("desc")`
460
+ # referencing an aggregate-level precondition, a query's own
461
+ # positional-param resolution) only ever saw whatever `@entities`/
462
+ # `attributes`/`@named_givens`/`@value_objects` held AS OF THAT
463
+ # EXACT TEXTUAL LINE — never what the aggregate's block would go
464
+ # on to declare after it. Three real, confirmed cases in the
465
+ # self-hosted meta-domain violate the "declare before you
466
+ # reference" convention every other resolution rule relies on
467
+ # (`command "Handler"` before `entity "Handler"`, same for
468
+ # Member/Dispatch — see docs/resolution-rules/
469
+ # implicit-append-fields.md's own "Known limitations").
470
+ #
471
+ # This is the SAME move `BluebookBuilder` already makes one level
472
+ # UP, at the CHAPTER level — build every aggregate first, THEN run
473
+ # cross-referential validation (`validate_query_hops!`,
474
+ # `validate_projected_fields!`, `validate_no_bidirectional_
475
+ # references!`) once `@aggregates` is fully populated — extended
476
+ # one level down: `entity`/`command`/`query` now only QUEUE a
477
+ # descriptor (`@pending_entities`/`@pending_commands`/
478
+ # `@pending_queries`, each preserving its own declared order),
479
+ # and `#build` drains them here, in this exact order, BEFORE any
480
+ # of the existing `seal_*` validations (which already assume
481
+ # `@commands`/`@entities`/`@queries` are the real, final, built
482
+ # objects) — entities FIRST and fully, since a command's own
483
+ # `sets :list, append: {...}` needs a list's element entity
484
+ # already built (`.attributes` populated) to resolve against, not
485
+ # just named.
486
+ #
487
+ # `attribute`/`value_object`/`identified_by`/`given` (block form)
488
+ # are NOT deferred — they still build eagerly during
489
+ # `instance_eval`, unchanged. Nothing reads `@entities`/
490
+ # `@commands`/`@queries` from anywhere OTHER than `#build` and its
491
+ # own private helpers (checked directly), so nothing else in this
492
+ # file needed to change for this to be safe.
493
+ def drain_pending!
494
+ @entities = @pending_entities.map do |name, block|
495
+ EntityBuilder.build(name, owner_value_objects: @value_objects + closed_sets,
496
+ owner_named_givens: @entity_named_givens,
497
+ identity_name_prefix: "#{Naming.demodulise(@name)}#{Naming.demodulise(name)}",
498
+ identity_value_object_installer: ->(value_object) { @value_objects << value_object },
499
+ &block)
500
+ end
501
+
502
+ @commands = @pending_commands.map do |name, from, block|
503
+ CommandBuilder.build(name, owner: @name, from: from, named_givens: @named_givens,
504
+ owner_attributes: attributes,
505
+ owner_constructs: @value_objects + closed_sets + @entities, &block)
506
+ end
507
+
508
+ @queries = @pending_queries.map do |name, block|
509
+ QueryBuilder.build(name, owner_attributes: attributes, &block)
510
+ end
511
+ end
512
+
513
+ # `identified_by`'s own resolution pool (AttributeCollector#resolve_
514
+ # pending_identity!'s hook, S9) — an aggregate resolves a bare
515
+ # field's own value-object type against everything it declares
516
+ # itself, own inline closed sets included.
517
+ def identity_pool = @value_objects + closed_sets
518
+
519
+ def identity_value_object_name = "#{Naming.demodulise(@name)}Identity"
520
+
521
+ def install_identity_value_object!(value_object)
522
+ @value_objects << value_object
523
+ end
524
+
525
+ # LEGACY — see `has_many`/`has_one`/`belongs_to`'s own comment;
526
+ # byte-identical to what those three did before this slice.
527
+ def legacy_has_many(type, as:, optional: false)
528
+ plural = Naming.demodulise(type)
529
+ reference_to_impl(Naming.singularize(plural), as: as || Naming.snake(plural).to_sym, optional: optional)
530
+ end
531
+
532
+ def legacy_has_one(type, as:, optional: false)
533
+ reference_to_impl(type, as: as || Naming.snake(Naming.demodulise(type)).to_sym, optional: optional)
534
+ end
535
+
536
+ # Every reference is told which Aggregate declares it, so it can
537
+ # find the chapter and resolve its target.
538
+ #
539
+ # Stamped HERE, at build, rather than at `reference_to`, because a command
540
+ # builder does not hold the aggregate and should not learn to. And
541
+ # deliberately across every list that can carry one — a reference the walk
542
+ # missed would resolve to nil, and `resolve_references` SKIPS a nil target,
543
+ # so the guarantee would go quiet instead of going red. That is the exact
544
+ # shape of the bug that let an Account belong to an unregistered customer
545
+ # fourteen times over.
546
+ def stamp_references(ir)
547
+ reference_bearing_attributes.each { |attribute| attribute.type.declared_in = ir }
548
+ end
549
+
550
+ # AN OWNED PIECE'S OWN `reference_to` IS AN EDGE THIS AGGREGATE
551
+ # POINTS ACROSS TOO (S9, ADR 0025 — "entity/aggregate shared
552
+ # vocabulary") — a ring closing through a contained piece (Board
553
+ # -> Board::Card -> Product -> Board) is the same "no boundary
554
+ # anyone can reason about alone" `validate_no_bidirectional_
555
+ # references!` already refuses for a direct aggregate-to-
556
+ # aggregate ring; it was invisible before this because only
557
+ # `AggregateBuilder#reference_to` ever fed `@reference_targets`,
558
+ # never `EntityBuilder#reference_to`. Command/query reference
559
+ # ARGUMENTS are deliberately excluded — they are data flowing
560
+ # through a dispatch, not persisted state the graph a cycle
561
+ # means anything over.
562
+ def entity_reference_targets
563
+ @entities.flat_map { |entity| entity.attributes.select(&:reference?).map { |a| a.type.target_name.to_s } }
564
+ end
565
+
566
+ def reference_bearing_attributes
567
+ lists = [attributes, *@commands.map(&:attributes), *@queries.map(&:attributes)]
568
+ @entities.each do |entity|
569
+ lists << entity.attributes
570
+ lists.concat(entity.commands.map(&:attributes))
571
+ lists.concat(entity.queries.map(&:attributes))
572
+ end
573
+
574
+ lists.flatten.select(&:reference?)
575
+ end
576
+
577
+ # A mutation must name a field the aggregate actually HAS.
578
+ #
579
+ # NOT moved to the language, and deliberately so. The language says only
580
+ # `given("a mutation names a target") { !target.value.to_s.empty? }` —
581
+ # non-emptiness — because saying more means reaching a list that lives on
582
+ # a DIFFERENT root : a command's changes hang off Command, the fields they
583
+ # name hang off Aggregate, and a given is a closed predicate over its own
584
+ # state. Aggregate.Seal is the right shape and cannot see commands ; the
585
+ # reference trick that rescued "attributes use value-object types" needs a
586
+ # root to point at, and an aggregate's fields are a value-object list, not
587
+ # roots. This is the second rule that cannot port for that reason — the
588
+ # first is read-model uniqueness — and both wait on the same thing : a
589
+ # quantifier, or fields promoted to roots.
590
+ #
591
+ # So it lives here, at build, where every declaration is present. Found by
592
+ # writing `then_set :disputed_by` on CardPayment before the field existed :
593
+ # it wrote into nothing, refused nothing, and every check stayed green.
594
+ # A DEFAULT FILLS THE SHAPE IT IS DECLARED ON, or it fills nothing.
595
+ #
596
+ # `attribute :cover, one_of("covered", "open"), default: "open"` builds
597
+ # cleanly and then refuses EVERY create at dispatch — "cover is a Cover,
598
+ # pass its fields as an object" — because the value object wants its
599
+ # fields and got a bare string. The bluebook is wrong at the line where
600
+ # it is written and says so nowhere near it.
601
+ #
602
+ # It cost a corpus member 33 refusals out of 40 steps, with every gate
603
+ # green throughout: the refusals were perfectly consistent, which is
604
+ # consistency about nothing. `till.bluebook` has always had the right shape
605
+ # — `default: { cents: 0 }`.
606
+ #
607
+ # A PRIMITIVE takes a scalar and a VALUE OBJECT takes its fields, so the
608
+ # test is simply which one the type names. Nothing here guesses at the
609
+ # keys: a default that is a Hash is left to `Value.for_attribute`, which
610
+ # is where a wrong FIELD belongs.
611
+ def seal_defaults
612
+ shapes = @value_objects.map { |shape| shape.hecks_name.to_s }
613
+
614
+ attributes.each do |attribute|
615
+ next if attribute.default.nil? || attribute.default.is_a?(Hash)
616
+ next unless shapes.include?(attribute.type.to_s)
617
+
618
+ raise Malformed,
619
+ "#{@name}.#{attribute.name} defaults to #{attribute.default.inspect}, but " \
620
+ "#{attribute.type} is a value object — a default fills its FIELDS " \
621
+ "(default: { ... }), and a bare value refuses every create instead"
622
+ end
623
+ end
624
+
625
+ # A command's `from:` guard needs a lifecycle field to check
626
+ # against — declared at BUILD time (S10, ADR 0025), the same
627
+ # point every other "does this actually resolve" check in this
628
+ # file runs, rather than left to crash `enforce_lifecycle_
629
+ # guard` the first time such a command is ever dispatched.
630
+ def seal_lifecycle_guards
631
+ return if @lifecycle
632
+
633
+ @commands.each do |command|
634
+ next unless command.from
635
+
636
+ raise Malformed,
637
+ "#{@name}.#{command.hecks_name} guards from: #{Array(command.from).inspect}, but " \
638
+ "#{@name} declares no lifecycle — from: checks a lifecycle field, and there is " \
639
+ "none here to check"
640
+ end
641
+ end
642
+
643
+ # `projects`'s OWN half of "does this actually resolve" (S12,
644
+ # ADR 0025) — the LOCAL half only: `reference` must name a real
645
+ # reference-typed attribute this aggregate declares, and
646
+ # `name` must not collide with an attribute already declared
647
+ # (a projected field is its own kind of field, never a second
648
+ # spelling of one that already exists). The TARGET aggregate's
649
+ # own field is checked separately, once every aggregate in the
650
+ # chapter is real — see BluebookBuilder#validate_projected_
651
+ # fields!'s own comment for why that half cannot happen here.
652
+ def seal_projected_fields
653
+ declared = attributes.map { |attribute| attribute.name.to_sym }
654
+
655
+ @projected_fields.each do |field|
656
+ if declared.include?(field.name)
657
+ raise Malformed,
658
+ "#{@name}.projects :#{field.name} names a field #{@name} already declares — " \
659
+ "a projected field is never a second spelling of one that already exists"
660
+ end
661
+
662
+ reference_attribute = attributes.find { |attribute| attribute.name == field.reference }
663
+ unless reference_attribute&.reference?
664
+ raise Malformed,
665
+ "#{@name}.projects :#{field.name} reads through #{field.reference.inspect}, which " \
666
+ "#{@name} never declares as a reference_to — projects reads through a REFERENCE, " \
667
+ "never a value object or a scalar"
668
+ end
669
+ end
670
+ end
671
+
672
+ def seal_mutation_targets
673
+ known = attributes.map { |attribute| attribute.name.to_sym }
674
+ known << @lifecycle.field.to_sym if @lifecycle
675
+
676
+ @commands.each do |command|
677
+ command.mutations.each do |mutation|
678
+ # `:delegate` — CommandBuilder#delegates_to's own comment —
679
+ # targets no field of THIS aggregate at all; its `target`
680
+ # names an "Entity.Command" pair instead, checked when the
681
+ # command builds (`delegates_to`'s own `rpartition` guard)
682
+ # and again at dispatch time (`CommandInterpreter
683
+ # #step_delegate_to_entity`, which refuses a real one that
684
+ # names no such entity or command). Sealing THIS check
685
+ # against it would refuse every delegating command outright.
686
+ next if mutation.op == :delegate
687
+ next if known.include?(mutation.target.to_sym)
688
+
689
+ raise Malformed,
690
+ "#{@name}.#{command.hecks_name} sets #{mutation.target}, which #{@name} " \
691
+ "never declares — a mutation into a field that does not exist " \
692
+ "writes nothing and refuses nothing"
693
+ end
694
+ end
695
+ end
696
+
697
+ # A query must ask about a field the aggregate actually HAS — the same
698
+ # seal `then_set` gets, closing the same silence: a where over a field
699
+ # nothing declares matches nothing and refuses nothing, forever, on
700
+ # every adapter. Three more silences close with it. A dotted path may
701
+ # reach through the value-object graph but must LAND on a scalar
702
+ # member (QuerySpecification::FieldPath is the one walk every engine
703
+ # now shares) — landing on a value object hands SQL a JSON object
704
+ # where the reference interpreter unwraps a hash. An ordered
705
+ # comparator (lt/gt/gte/lte) must land on a numeric leaf — over text
706
+ # the reference interpreter quietly matches no rows while SQL
707
+ # compares lexicographically. And a :symbol value must name one of
708
+ # the query's own declared arguments, or it resolves to nil at
709
+ # dispatch and matches nothing.
710
+ ORDERED_COMPARATORS = %i[lt lte gt gte].freeze
711
+
712
+ def seal_query_targets
713
+ query_surfaces.each do |owner, fields, lifecycle, queries|
714
+ queries.each do |query|
715
+ query.wheres.each do |clause|
716
+ seal_query_field(owner, query, fields, lifecycle, clause.field)
717
+ seal_ordered_comparator(owner, query, fields, clause)
718
+ infer_local_query_argument(query, fields, lifecycle, clause)
719
+ seal_query_argument(owner, query, clause.value) unless clause.field.to_s.include?("/")
720
+ end
721
+ seal_query_field(owner, query, fields, lifecycle, query.order_by.field, ordering: true) if query.order_by
722
+ seal_query_argument(owner, query, query.limit&.value)
723
+ seal_query_argument(owner, query, query.offset&.value)
724
+ end
725
+ end
726
+ end
727
+
728
+ def query_surfaces
729
+ [[@name, attributes, @lifecycle, @queries]] +
730
+ @entities.map { |entity| ["#{@name}::#{entity.hecks_name}", entity.attributes, entity.lifecycle, entity.queries] }
731
+ end
732
+
733
+ # `/` CROSSES INTO ANOTHER RECORD, `.` WALKS FIELDS INSIDE THIS
734
+ # ONE (ADR 0025, "References") — the operator answers which
735
+ # kind of path this is now, not a name collision to arbitrate,
736
+ # so a hop is routed to its own method before any `.`-splitting
737
+ # runs at all; `seal_query_hop` below never sees a field this
738
+ # one would also have tried to resolve as a local dotted walk.
739
+ def seal_query_field(owner, query, fields, lifecycle, field, ordering: false)
740
+ return seal_query_hop(owner, query, fields, field, ordering: ordering) if field.to_s.include?("/")
741
+
742
+ name, *nested = field.to_s.split(".")
743
+ attribute = fields.find { |candidate| candidate.name.to_s == name }
744
+ if nested.empty? && attribute
745
+ refuse_ambiguous_comparison!(owner, query, field, attribute)
746
+ return
747
+ end
748
+ return if nested.empty? && lifecycle&.field.to_s == name
749
+ return if nested.any? && attribute && scalar_path?(attribute, nested)
750
+
751
+ if nested.any? && attribute && resolves?(attribute, nested)
752
+ raise Malformed,
753
+ "#{owner}.#{query.hecks_name} asks about #{field}, which lands on a " \
754
+ "value object, not a scalar — a dotted query path ends on a scalar " \
755
+ "member, or the engines answer it differently"
756
+ end
757
+
758
+ raise Malformed,
759
+ "#{owner}.#{query.hecks_name} asks about #{field}, which #{owner} " \
760
+ "never declares — a query over a field that does not exist " \
761
+ "matches nothing and refuses nothing"
762
+ end
763
+
764
+ # ORDER BY refuses a hop OUTRIGHT, right here — unlike a WHERE
765
+ # hop (deferred below), this doesn't need the target's shape to
766
+ # answer: an ask is ordered by what its own answering rows
767
+ # hold, and a hop answers with a candidate set, not a sort key
768
+ # (see Runtime::ReferenceHop).
769
+ #
770
+ # A WHERE hop is only RECOGNISED here, and CHECKED LATER. The
771
+ # head names one of this aggregate's own references, which is
772
+ # answerable now — a Reference knows its own target_name at
773
+ # declaration. What it points AT is not: stamp_references has
774
+ # already run by this point, but the chapter (Bluebook, and the
775
+ # owning aggregate's OWN place in it) does not exist yet, so
776
+ # Reference#resolve would answer nil for every target in the
777
+ # file, including ones declared above this one. The tail, and
778
+ # whether the target even exists, are BluebookBuilder's
779
+ # business — see validate_query_hops!, which runs once the
780
+ # chapter is real, for exactly the reason
781
+ # validate_no_bidirectional_references! already gives for
782
+ # living at that same later point.
783
+ def seal_query_hop(owner, query, fields, field, ordering:)
784
+ unless QuerySpecification::HopPath.hop_head?(field, fields)
785
+ raise Malformed,
786
+ "#{owner}.#{query.hecks_name} asks about #{field}, which #{owner} " \
787
+ "never declares — a query over a field that does not exist " \
788
+ "matches nothing and refuses nothing"
789
+ end
790
+
791
+ return unless ordering
792
+
793
+ raise Malformed,
794
+ "#{owner}.#{query.hecks_name} orders by #{field}, which hops through " \
795
+ "a reference — an ask is ordered by what its own answering rows " \
796
+ "hold, and a hop answers with a candidate set, not a sort key"
797
+ end
798
+
799
+ def seal_ordered_comparator(owner, query, fields, clause)
800
+ return unless ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym)
801
+
802
+ # A WHERE clause hopping through a reference with an ordered
803
+ # comparator is legitimate ("client whose balance > 500") —
804
+ # unlike ORDER BY (refused outright in seal_query_field, see
805
+ # its own comment), a where-clause hop answers a real
806
+ # candidate set either way, ordered or not. Deferred for the
807
+ # same reason any other hop is: whether the tail is even
808
+ # numeric is BluebookBuilder#validate_query_hops!'s question
809
+ # to ask of the TARGET's shape, not this aggregate's own.
810
+ return if clause.field.to_s.include?("/") && QuerySpecification::HopPath.hop_head?(clause.field, fields)
811
+
812
+ name, *nested = clause.field.to_s.split(".")
813
+ attribute = fields.find { |candidate| candidate.name.to_s == name }
814
+ return if attribute &&
815
+ QuerySpecification::FieldPath.numeric?(attribute, nested) { |type| declared_value_object(type) }
816
+
817
+ held = attribute ? "holds no number" : "is the lifecycle field, which holds text"
818
+ raise Malformed,
819
+ "#{owner}.#{query.hecks_name} compares #{clause.field} with #{clause.op}, " \
820
+ "but #{clause.field} #{held} — an ordered comparison needs a numeric " \
821
+ "field, and over anything else the adapters answer differently or not at all"
822
+ end
823
+
824
+ def seal_query_argument(owner, query, value)
825
+ return unless value.is_a?(Symbol)
826
+ return if query.attribute(value)
827
+
828
+ raise Malformed,
829
+ "#{owner}.#{query.hecks_name} resolves :#{value} from its arguments, " \
830
+ "but declares no #{value} attribute — an argument that does not exist " \
831
+ "resolves to nil and matches nothing"
832
+ end
833
+
834
+ # A symbolic right-hand side is a query input. When the compared path
835
+ # lands on this owner's declared shape, its type is already known and
836
+ # repeating an `attribute` line inside the query adds no information.
837
+ # Reference hops are resolved only after the whole chapter has been
838
+ # owner-stamped; BluebookBuilder performs the identical inference for
839
+ # those deferred paths.
840
+ def infer_local_query_argument(query, fields, lifecycle, clause)
841
+ name = clause.value
842
+ return unless name.is_a?(Symbol)
843
+ return if query.attribute(name)
844
+ return if clause.field.to_s.include?("/")
845
+
846
+ head, *nested = clause.field.to_s.split(".")
847
+ leaf = if nested.empty? && lifecycle&.field.to_s == head
848
+ Attribute.new(name: name, type: String)
849
+ else
850
+ root = fields.find { |candidate| candidate.name.to_s == head }
851
+ found = root && QuerySpecification::FieldPath.leaf_attribute(root, nested) do |type|
852
+ declared_value_object(type)
853
+ end
854
+ found && Attribute.new(name: name, type: found.type, list: found.list?)
855
+ end
856
+ query.attributes << leaf if leaf
857
+ end
858
+
859
+ # A BARE FIELD NAMING A VALUE OBJECT HAS TO SAY WHICH MEMBER IT
860
+ # MEANS, when more than one could answer. The dotted case above
861
+ # already refuses a path that lands on a value object rather than
862
+ # a scalar; a bare name was returning unconditionally, so
863
+ # `where(frequency: ...)` against a StatementFrequency
864
+ # (cadence, retention_months, paper_fee_cents) compiled — and the
865
+ # engines then disagreed about which member it meant, one taking
866
+ # the FIRST numeric and another declining to unwrap at all.
867
+ #
868
+ # Unambiguous is: exactly one member, whatever its type, or
869
+ # exactly one NUMERIC member among several (Money's `cents`
870
+ # beside its `currency` — the reading every engine already
871
+ # shared, and what the corpus relies on). Anything else names
872
+ # its member with a dotted path, which already works.
873
+ #
874
+ # A list is exempt: `contains` over a `list_of` reads element
875
+ # membership, not a scalar comparison, and has its own agreed
876
+ # reading across the engines.
877
+ def refuse_ambiguous_comparison!(owner, query, field, attribute)
878
+ return if attribute.list?
879
+
880
+ value_object = declared_value_object(attribute.type.to_s)
881
+ return unless value_object
882
+
883
+ members = QuerySpecification::Common::Comparison.ambiguous_members(value_object)
884
+ return if members.empty?
885
+
886
+ raise Malformed,
887
+ "#{owner}.#{query.hecks_name} asks about #{field}, which names #{attribute.type} — " \
888
+ "it has #{members.size} members (#{members.join(', ')}) and no single one a " \
889
+ "comparison can mean; name the member (#{field}.#{members.first})"
890
+ end
891
+
892
+ def scalar_path?(attribute, nested)
893
+ QuerySpecification::FieldPath.scalar_leaf?(attribute, nested) { |type| declared_value_object(type) }
894
+ end
895
+
896
+ def resolves?(attribute, nested)
897
+ !QuerySpecification::FieldPath.leaf_attribute(attribute, nested) { |type| declared_value_object(type) }.nil?
898
+ end
899
+
900
+ def declared_value_object(type_name)
901
+ (@value_objects + closed_sets).find { |shape| shape.hecks_name.to_s == type_name }
902
+ end
903
+ end
904
+ end
905
+ end
906
+ end