hecks 2.7.0 → 2.9.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 (463) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +11 -0
  3. data/lib/hecks/adapters/driven/claude_code.rb +4 -104
  4. data/lib/hecks/adapters/driven/d1.rb +44 -237
  5. data/lib/hecks/adapters/driven/folder.rb +17 -76
  6. data/lib/hecks/adapters/driven/google_authentication.rb +15 -49
  7. data/lib/hecks/adapters/driven/governance_authorization.rb +23 -70
  8. data/lib/hecks/adapters/driven/heki/journal.rb +8 -41
  9. data/lib/hecks/adapters/driven/heki/saga_store.rb +12 -54
  10. data/lib/hecks/adapters/driven/heki/snapshot.rb +4 -12
  11. data/lib/hecks/adapters/driven/heki.rb +13 -42
  12. data/lib/hecks/adapters/driven/identity_registry.rb +3 -14
  13. data/lib/hecks/adapters/driven/in_memory_ordering.rb +9 -26
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +2 -14
  15. data/lib/hecks/adapters/driven/lambda/client.rb +10 -51
  16. data/lib/hecks/adapters/driven/lambda.rb +10 -84
  17. data/lib/hecks/adapters/driven/local_storage.rb +5 -40
  18. data/lib/hecks/adapters/driven/memory.rb +18 -99
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +5 -21
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +6 -28
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +3 -12
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +4 -30
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +34 -108
  24. data/lib/hecks/adapters/driven/postgres.rb +145 -134
  25. data/lib/hecks/adapters/driven/postgres_era.adapter +4 -15
  26. data/lib/hecks/adapters/driven/prism.rb +7 -30
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +21 -121
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +8 -40
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +26 -82
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +40 -84
  31. data/lib/hecks/adapters/driven/sqlite.rb +128 -65
  32. data/lib/hecks/adapters/driven/tenant_provisioner.rb +15 -37
  33. data/lib/hecks/adapters/driven.rb +5 -36
  34. data/lib/hecks/adapters/driving/github_webhook.rb +8 -76
  35. data/lib/hecks/behaviors/dsl.rb +7 -25
  36. data/lib/hecks/behaviors/expectations.rb +32 -142
  37. data/lib/hecks/behaviors/ir.rb +7 -20
  38. data/lib/hecks/behaviors/rspec.rb +6 -17
  39. data/lib/hecks/behaviors/runner.rb +10 -34
  40. data/lib/hecks/behaviors.rb +4 -8
  41. data/lib/hecks/bluebook/aggregate.rb +9 -75
  42. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +9 -38
  43. data/lib/hecks/bluebook/assembly/build.rb +4 -15
  44. data/lib/hecks/bluebook/assembly/contract.rb +15 -136
  45. data/lib/hecks/bluebook/assembly/contracts.rb +54 -174
  46. data/lib/hecks/bluebook/assembly/marks.rb +31 -223
  47. data/lib/hecks/bluebook/assembly/specializer.rb +6 -52
  48. data/lib/hecks/bluebook/assembly.rb +6 -45
  49. data/lib/hecks/bluebook/attribute.rb +12 -61
  50. data/lib/hecks/bluebook/behaviour/aggregate.rb +10 -39
  51. data/lib/hecks/bluebook/behaviour/attribute.rb +3 -14
  52. data/lib/hecks/bluebook/behaviour/chapter.rb +17 -42
  53. data/lib/hecks/bluebook/behaviour/command.rb +14 -66
  54. data/lib/hecks/bluebook/behaviour/domain_port.rb +4 -20
  55. data/lib/hecks/bluebook/behaviour/entity.rb +7 -27
  56. data/lib/hecks/bluebook/behaviour/hexagon.rb +23 -41
  57. data/lib/hecks/bluebook/behaviour/lifecycle.rb +15 -40
  58. data/lib/hecks/bluebook/behaviour/policy.rb +23 -52
  59. data/lib/hecks/bluebook/behaviour/process_manager.rb +30 -57
  60. data/lib/hecks/bluebook/behaviour/read_model.rb +38 -26
  61. data/lib/hecks/bluebook/behaviour/traits.rb +27 -64
  62. data/lib/hecks/bluebook/behaviour/value_object.rb +5 -24
  63. data/lib/hecks/bluebook/capabilities.rb +17 -49
  64. data/lib/hecks/bluebook/chapter.rb +10 -48
  65. data/lib/hecks/bluebook/command.rb +23 -81
  66. data/lib/hecks/bluebook/domain_port.rb +14 -63
  67. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +50 -229
  68. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +50 -377
  69. data/lib/hecks/bluebook/dsl/attribute_collector.rb +33 -204
  70. data/lib/hecks/bluebook/dsl/binding_proxy.rb +17 -65
  71. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +86 -708
  72. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +56 -170
  73. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  74. data/lib/hecks/bluebook/dsl/command_builder.rb +74 -546
  75. data/lib/hecks/bluebook/dsl/const_shim.rb +8 -41
  76. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +12 -87
  77. data/lib/hecks/bluebook/dsl/entity_builder.rb +44 -330
  78. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +20 -269
  79. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +26 -189
  80. data/lib/hecks/bluebook/dsl/identity_declaration.rb +13 -88
  81. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +6 -20
  82. data/lib/hecks/bluebook/dsl/policy_builder.rb +16 -88
  83. data/lib/hecks/bluebook/dsl/port_builder.rb +2 -6
  84. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +22 -96
  85. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +31 -184
  86. data/lib/hecks/bluebook/dsl/query_builder.rb +11 -42
  87. data/lib/hecks/bluebook/dsl/read_model_builder.rb +75 -201
  88. data/lib/hecks/bluebook/dsl/rule_reference.rb +22 -163
  89. data/lib/hecks/bluebook/dsl/translation_builder.rb +28 -114
  90. data/lib/hecks/bluebook/dsl/value_object_builder.rb +10 -124
  91. data/lib/hecks/bluebook/dsl/word_gate.rb +16 -162
  92. data/lib/hecks/bluebook/dsl/world_builder.rb +19 -106
  93. data/lib/hecks/bluebook/entity.rb +13 -71
  94. data/lib/hecks/bluebook/expression/ast_json.rb +24 -190
  95. data/lib/hecks/bluebook/expression/ast_reader.rb +12 -54
  96. data/lib/hecks/bluebook/expression/canonical_form.rb +11 -88
  97. data/lib/hecks/bluebook/expression/evaluator.rb +47 -295
  98. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +30 -147
  99. data/lib/hecks/bluebook/expression/resolver.rb +37 -660
  100. data/lib/hecks/bluebook/hexagon.rb +5 -28
  101. data/lib/hecks/bluebook/lifecycle.rb +5 -16
  102. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -7
  103. data/lib/hecks/bluebook/meta_validator/judge.rb +111 -361
  104. data/lib/hecks/bluebook/meta_validator/plan.rb +33 -162
  105. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -7
  106. data/lib/hecks/bluebook/meta_validator/readings.rb +56 -342
  107. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +24 -176
  108. data/lib/hecks/bluebook/meta_validator/shapes.rb +54 -268
  109. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +47 -308
  110. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +7 -43
  111. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -18
  112. data/lib/hecks/bluebook/meta_validator.rb +79 -349
  113. data/lib/hecks/bluebook/model_check/client_profile.rb +10 -91
  114. data/lib/hecks/bluebook/model_check.rb +102 -600
  115. data/lib/hecks/bluebook/pattern_subset.rb +11 -69
  116. data/lib/hecks/bluebook/process_manager.rb +13 -83
  117. data/lib/hecks/bluebook/project_loader.rb +3 -39
  118. data/lib/hecks/bluebook/project_register.rb +12 -60
  119. data/lib/hecks/bluebook/query.rb +9 -48
  120. data/lib/hecks/bluebook/read_model.rb +57 -81
  121. data/lib/hecks/bluebook/reference.rb +7 -41
  122. data/lib/hecks/bluebook/smoke_test.rb +8 -98
  123. data/lib/hecks/bluebook/synthesizer.rb +13 -62
  124. data/lib/hecks/bluebook/translation.rb +26 -62
  125. data/lib/hecks/bluebook/value_object.rb +11 -36
  126. data/lib/hecks/bluebook.rb +5 -22
  127. data/lib/hecks/cache_dir.rb +84 -0
  128. data/lib/hecks/cli/document.rb +86 -0
  129. data/lib/hecks/cli/ir.rb +45 -0
  130. data/lib/hecks/cli/mcp.rb +31 -0
  131. data/lib/hecks/cli/mcp_door.rb +366 -0
  132. data/lib/hecks/cli/model_check.rb +203 -0
  133. data/lib/hecks/cli/project_cli.rb +90 -0
  134. data/lib/hecks/cli/project_diagrams.rb +36 -0
  135. data/lib/hecks/cli/run.rb +173 -0
  136. data/lib/hecks/cli/smoke_test.rb +45 -0
  137. data/lib/hecks/cli/stores.rb +53 -0
  138. data/lib/hecks/cli.rb +160 -0
  139. data/lib/hecks/construct.rb +8 -34
  140. data/lib/hecks/deploy/bluebook/deploy.bluebook +33 -142
  141. data/lib/hecks/deploy/bluebook/deploy.hecksagon +2 -9
  142. data/lib/hecks/embryonaut_bluebook/lock.rb +3 -35
  143. data/lib/hecks/embryonaut_bluebook/shape.rb +1 -17
  144. data/lib/hecks/embryonaut_bluebook/vendor.rb +8 -53
  145. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +4 -19
  146. data/lib/hecks/embryonaut_bluebook.rb +4 -71
  147. data/lib/hecks/facade/cli_door.rb +24 -106
  148. data/lib/hecks/facade/cli_runner.rb +28 -172
  149. data/lib/hecks/facade/command_request.rb +13 -33
  150. data/lib/hecks/facade/handle.rb +34 -121
  151. data/lib/hecks/facade/json_door.rb +31 -146
  152. data/lib/hecks/facade/surface/aggregate_door.rb +39 -104
  153. data/lib/hecks/facade/surface/chapter.rb +10 -54
  154. data/lib/hecks/facade/surface.rb +8 -23
  155. data/lib/hecks/facade.rb +10 -25
  156. data/lib/hecks/forms/app.rb +15 -70
  157. data/lib/hecks/forms/command_form_renderer.rb +10 -29
  158. data/lib/hecks/forms/field_renderer.rb +17 -156
  159. data/lib/hecks/forms/field_shape.rb +30 -222
  160. data/lib/hecks/forms/html.rb +12 -47
  161. data/lib/hecks/forms/index_renderer.rb +1 -4
  162. data/lib/hecks/forms/page.rb +3 -9
  163. data/lib/hecks/forms/params.rb +14 -60
  164. data/lib/hecks/forms/port_argument.rb +2 -19
  165. data/lib/hecks/forms/query_form_renderer.rb +4 -79
  166. data/lib/hecks/forms/record_renderer.rb +8 -75
  167. data/lib/hecks/forms/record_table.rb +7 -50
  168. data/lib/hecks/forms/reference_options.rb +3 -30
  169. data/lib/hecks/forms/value_object_shape.rb +8 -35
  170. data/lib/hecks/forms.rb +5 -33
  171. data/lib/hecks/fqn.rb +5 -12
  172. data/lib/hecks/framework/bluebook/compliance.bluebook +16 -65
  173. data/lib/hecks/framework/bluebook/console_settings.bluebook +47 -134
  174. data/lib/hecks/framework/bluebook/framework.hecksagon +5 -14
  175. data/lib/hecks/framework/bluebook/governance.bluebook +13 -34
  176. data/lib/hecks/framework/bluebook/identity.bluebook +11 -26
  177. data/lib/hecks/framework/bluebook/privacy.bluebook +8 -33
  178. data/lib/hecks/framework.rb +11 -80
  179. data/lib/hecks/freezer.rb +5 -39
  180. data/lib/hecks/grammar/expression.bluebook +15 -40
  181. data/lib/hecks/grammar/translation.bluebook +3 -6
  182. data/lib/hecks/grammar.rb +16 -41
  183. data/lib/hecks/ir.rb +14 -83
  184. data/lib/hecks/language/adapter.bluebook +2 -7
  185. data/lib/hecks/language/bluebook/aggregate.bluebook +56 -221
  186. data/lib/hecks/language/bluebook/attaches/paging.bluebook +6 -15
  187. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -83
  188. data/lib/hecks/language/bluebook/bluebook.hecksagon +3 -15
  189. data/lib/hecks/language/bluebook/command.bluebook +27 -144
  190. data/lib/hecks/language/bluebook/entity.bluebook +24 -97
  191. data/lib/hecks/language/bluebook/policy.bluebook +16 -67
  192. data/lib/hecks/language/bluebook/process_manager.bluebook +28 -151
  193. data/lib/hecks/language/bluebook/projection.bluebook +126 -60
  194. data/lib/hecks/language/bluebook/query.bluebook +12 -43
  195. data/lib/hecks/language/bluebook/shape.bluebook +23 -73
  196. data/lib/hecks/language/bluebook/syntax.bluebook +51 -262
  197. data/lib/hecks/language/bluebook/vocabulary.bluebook +98 -197
  198. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +3 -10
  199. data/lib/hecks/language/hecksagon/domain_port.bluebook +4 -19
  200. data/lib/hecks/language/hecksagon/hecksagon.bluebook +6 -25
  201. data/lib/hecks/language/hecksagon/port_operation.bluebook +3 -14
  202. data/lib/hecks/language/oidc.json +35 -0
  203. data/lib/hecks/language/port.bluebook +4 -17
  204. data/lib/hecks/language/translation/translation.bluebook +3 -9
  205. data/lib/hecks/language/translation/translation_aggregate.bluebook +5 -24
  206. data/lib/hecks/language/world/wiring.bluebook +1 -3
  207. data/lib/hecks/language/world/world.bluebook +5 -7
  208. data/lib/hecks/literal.rb +8 -50
  209. data/lib/hecks/mcp_door_scope.rb +95 -0
  210. data/lib/hecks/mcp_stdio_guard.rb +4 -41
  211. data/lib/hecks/naming.rb +29 -137
  212. data/lib/hecks/ports/access_control.rb +7 -23
  213. data/lib/hecks/ports/agent/answers.rb +5 -23
  214. data/lib/hecks/ports/agent.rb +47 -172
  215. data/lib/hecks/ports/authentication.rb +12 -39
  216. data/lib/hecks/ports/authorization.rb +16 -43
  217. data/lib/hecks/ports/clock.rb +5 -45
  218. data/lib/hecks/ports/extraction.rb +7 -12
  219. data/lib/hecks/ports/identity_assignment.rb +8 -29
  220. data/lib/hecks/ports/identity_generation.rb +6 -29
  221. data/lib/hecks/ports/identity_resolution.rb +2 -10
  222. data/lib/hecks/ports/key_vault.rb +9 -45
  223. data/lib/hecks/ports/persistence/append_only.rb +62 -65
  224. data/lib/hecks/ports/persistence/binding_policy.rb +4 -14
  225. data/lib/hecks/ports/persistence/codec_boundary.rb +20 -80
  226. data/lib/hecks/ports/persistence/null_saga_store.rb +4 -20
  227. data/lib/hecks/ports/persistence/plugin.rb +13 -42
  228. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +38 -141
  229. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +15 -115
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +16 -84
  231. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +7 -26
  232. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -22
  233. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +55 -181
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/events.rb +77 -0
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +12 -122
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +28 -128
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +73 -474
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +31 -124
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +45 -219
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +13 -108
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +18 -75
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +9 -49
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +6 -123
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +23 -55
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -72
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +7 -16
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +14 -115
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +13 -45
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +127 -554
  250. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +13 -63
  251. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +2 -17
  252. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +3 -14
  253. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +13 -58
  254. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -17
  255. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +9 -26
  256. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +9 -25
  257. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +22 -122
  258. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +34 -123
  259. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +5 -12
  260. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +3 -9
  261. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -14
  262. data/lib/hecks/ports/persistence/plugins/era.rb +4 -23
  263. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +7 -23
  264. data/lib/hecks/ports/persistence/postgres_dump.rb +4 -35
  265. data/lib/hecks/ports/persistence/remote_runtime.rb +2 -24
  266. data/lib/hecks/ports/persistence/repository_factory.rb +8 -27
  267. data/lib/hecks/ports/persistence/state_codec.rb +23 -103
  268. data/lib/hecks/ports/persistence.rb +7 -14
  269. data/lib/hecks/ports/projection.rb +42 -84
  270. data/lib/hecks/ports/query/in_memory.rb +10 -58
  271. data/lib/hecks/ports/query/ordering.rb +11 -32
  272. data/lib/hecks/ports/query.rb +14 -37
  273. data/lib/hecks/ports.rb +2 -8
  274. data/lib/hecks/projections/bootstrap_table.rb +11 -43
  275. data/lib/hecks/projections/deploy/fargate/assembly.rb +2 -15
  276. data/lib/hecks/projections/deploy/fargate/cdn.rb +1 -48
  277. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +2 -11
  278. data/lib/hecks/projections/deploy/fargate/check.rb +5 -14
  279. data/lib/hecks/projections/deploy/fargate/containers.rb +3 -78
  280. data/lib/hecks/projections/deploy/fargate/extras.rb +3 -44
  281. data/lib/hecks/projections/deploy/fargate/monitoring.rb +0 -44
  282. data/lib/hecks/projections/deploy/fargate/settings.rb +2 -54
  283. data/lib/hecks/projections/deploy/fargate/yaml.rb +13 -58
  284. data/lib/hecks/projections/deploy/fargate.rb +35 -202
  285. data/lib/hecks/projections/deploy/lambda.rb +151 -679
  286. data/lib/hecks/projections/deploy/preview/access_yaml.rb +1 -33
  287. data/lib/hecks/projections/deploy/preview/containers.rb +6 -111
  288. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +4 -56
  289. data/lib/hecks/projections/deploy/preview/script.rb +2 -14
  290. data/lib/hecks/projections/deploy/preview/settings.rb +3 -33
  291. data/lib/hecks/projections/deploy/preview/task_yaml.rb +2 -38
  292. data/lib/hecks/projections/deploy/preview/template.rb +2 -58
  293. data/lib/hecks/projections/deploy/preview.rb +2 -67
  294. data/lib/hecks/projections/deploy/scripts/settings.rb +3 -25
  295. data/lib/hecks/projections/deploy/scripts.rb +4 -57
  296. data/lib/hecks/projections/deploy/shared.rb +47 -146
  297. data/lib/hecks/projections/deploy/smoke.rb +4 -61
  298. data/lib/hecks/projections/deploy/template_diff/comparer.rb +3 -25
  299. data/lib/hecks/projections/deploy/template_diff/loader.rb +2 -11
  300. data/lib/hecks/projections/deploy/template_diff.rb +5 -57
  301. data/lib/hecks/projections/diagrams.rb +20 -582
  302. data/lib/hecks/projections/glossary/html.rb +14 -130
  303. data/lib/hecks/projections/glossary/markdown.rb +8 -69
  304. data/lib/hecks/projections/glossary/mermaid.rb +9 -66
  305. data/lib/hecks/projections/glossary/sensitivity.rb +5 -34
  306. data/lib/hecks/projections/glossary/sentences.rb +14 -159
  307. data/lib/hecks/projections/glossary.rb +30 -222
  308. data/lib/hecks/projections/ir.rb +3 -10
  309. data/lib/hecks/projections/model/deviations.rb +15 -42
  310. data/lib/hecks/projections/model.rb +12 -51
  311. data/lib/hecks/projections/oidc.rb +12 -56
  312. data/lib/hecks/projections/parser_table.rb +9 -28
  313. data/lib/hecks/projections/reference.rb +9 -16
  314. data/lib/hecks/projections/rust_vocabulary.rb +12 -250
  315. data/lib/hecks/projections/shape.rb +3 -29
  316. data/lib/hecks/projections/statements.rb +7 -86
  317. data/lib/hecks/projections/vocabulary.rb +6 -22
  318. data/lib/hecks/projections.rb +2 -16
  319. data/lib/hecks/projector/cli_projector.rb +60 -375
  320. data/lib/hecks/projector/docs_projector.rb +10 -201
  321. data/lib/hecks/projector/exporter.rb +46 -138
  322. data/lib/hecks/projector/ir_projector.rb +2 -9
  323. data/lib/hecks/projector/narrate_projector.rb +15 -189
  324. data/lib/hecks/projector/target.rb +13 -76
  325. data/lib/hecks/projector.rb +30 -121
  326. data/lib/hecks/query_specification/common/comparators.rb +5 -26
  327. data/lib/hecks/query_specification/common/comparison.rb +35 -143
  328. data/lib/hecks/query_specification/common/dsl.rb +8 -24
  329. data/lib/hecks/query_specification/common/null_policy.rb +20 -70
  330. data/lib/hecks/query_specification/common/options.rb +2 -6
  331. data/lib/hecks/query_specification/common/where_clause.rb +2 -10
  332. data/lib/hecks/query_specification/field_path.rb +37 -77
  333. data/lib/hecks/query_specification/hop_path.rb +12 -76
  334. data/lib/hecks/release/gem_pin.rb +2 -26
  335. data/lib/hecks/release/runner/ci_publisher.rb +93 -0
  336. data/lib/hecks/release/runner/commands.rb +66 -0
  337. data/lib/hecks/release/runner/console.rb +52 -0
  338. data/lib/hecks/release/runner/gem_publisher.rb +43 -0
  339. data/lib/hecks/release/runner/git.rb +48 -0
  340. data/lib/hecks/release/runner/npm_publisher.rb +75 -0
  341. data/lib/hecks/release/runner/preflight.rb +104 -0
  342. data/lib/hecks/release/runner/published.rb +55 -0
  343. data/lib/hecks/release/runner/tagger.rb +80 -0
  344. data/lib/hecks/release/runner.rb +215 -0
  345. data/lib/hecks/rendering.rb +4 -26
  346. data/lib/hecks/router/namespace_installer.rb +12 -36
  347. data/lib/hecks/router.rb +4 -8
  348. data/lib/hecks/runtime/aggregate_lock.rb +8 -33
  349. data/lib/hecks/runtime/boot_gates.rb +3 -18
  350. data/lib/hecks/runtime/caller.rb +19 -59
  351. data/lib/hecks/runtime/capability_graph.rb +5 -21
  352. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +16 -78
  353. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +23 -160
  354. data/lib/hecks/runtime/command_interpreter.rb +125 -307
  355. data/lib/hecks/runtime/command_rules/admissibility.rb +95 -366
  356. data/lib/hecks/runtime/command_rules/arithmetic.rb +66 -196
  357. data/lib/hecks/runtime/command_rules/authorization.rb +13 -45
  358. data/lib/hecks/runtime/command_rules/emission.rb +8 -21
  359. data/lib/hecks/runtime/command_rules/references.rb +23 -202
  360. data/lib/hecks/runtime/dependency_planning.rb +30 -102
  361. data/lib/hecks/runtime/dispatcher.rb +82 -241
  362. data/lib/hecks/runtime/entity_element.rb +40 -462
  363. data/lib/hecks/runtime/entity_interpreter.rb +65 -220
  364. data/lib/hecks/runtime/errors.rb +32 -115
  365. data/lib/hecks/runtime/event.rb +3 -25
  366. data/lib/hecks/runtime/identity.rb +18 -154
  367. data/lib/hecks/runtime/instance.rb +18 -150
  368. data/lib/hecks/runtime/interpreting.rb +11 -70
  369. data/lib/hecks/runtime/invocation.rb +28 -164
  370. data/lib/hecks/runtime/loader.rb +48 -186
  371. data/lib/hecks/runtime/outbox.rb +38 -261
  372. data/lib/hecks/runtime/policy_interpreter.rb +38 -200
  373. data/lib/hecks/runtime/port_operation_interpreter.rb +26 -87
  374. data/lib/hecks/runtime/query_interpreter.rb +45 -187
  375. data/lib/hecks/runtime/reaction_invocation.rb +36 -138
  376. data/lib/hecks/runtime/read_model_interpreter.rb +139 -208
  377. data/lib/hecks/runtime/rebuild_sweep.rb +8 -54
  378. data/lib/hecks/runtime/reference_hop.rb +8 -81
  379. data/lib/hecks/runtime/refusal_wording.rb +8 -89
  380. data/lib/hecks/runtime/registry/saga_persistence.rb +10 -102
  381. data/lib/hecks/runtime/registry/verification.rb +52 -225
  382. data/lib/hecks/runtime/registry/world_defaults.rb +4 -53
  383. data/lib/hecks/runtime/registry.rb +103 -244
  384. data/lib/hecks/runtime/remote_dispatcher.rb +37 -152
  385. data/lib/hecks/runtime/routing.rb +4 -15
  386. data/lib/hecks/runtime/saga_interpreter/correlation.rb +14 -61
  387. data/lib/hecks/runtime/saga_interpreter.rb +79 -263
  388. data/lib/hecks/runtime/saga_pending_dispatch.rb +2 -43
  389. data/lib/hecks/runtime/tenant_check.rb +6 -68
  390. data/lib/hecks/runtime/tenant_scope.rb +3 -28
  391. data/lib/hecks/runtime/value/admission.rb +16 -67
  392. data/lib/hecks/runtime/value/coercion.rb +155 -641
  393. data/lib/hecks/runtime/value/entity_list_coercion.rb +26 -219
  394. data/lib/hecks/runtime/value.rb +12 -84
  395. data/lib/hecks/runtime.rb +17 -65
  396. data/lib/hecks/storehouse.rb +146 -567
  397. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +8 -32
  398. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +6 -23
  399. data/lib/hecks/vendoring/git_environment.rb +2 -8
  400. data/lib/hecks/vendoring/git_source.rb +4 -10
  401. data/lib/hecks/vendoring.rb +7 -46
  402. data/lib/hecks/version.rb +3 -14
  403. data/lib/hecks/vocabulary.rb +5 -0
  404. data/lib/hecks.rb +32 -82
  405. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +8 -46
  406. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +11 -80
  407. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +10 -99
  408. metadata +30 -59
  409. data/lib/hecks/bench/cli.rb +0 -74
  410. data/lib/hecks/bench/environment.rb +0 -100
  411. data/lib/hecks/bench/postgres_probe.rb +0 -41
  412. data/lib/hecks/bench/report.rb +0 -116
  413. data/lib/hecks/bench/ruby_runner.rb +0 -151
  414. data/lib/hecks/bench/run.rb +0 -55
  415. data/lib/hecks/bench/rust_runner.rb +0 -165
  416. data/lib/hecks/bench/stats.rb +0 -75
  417. data/lib/hecks/bench/suite.rb +0 -172
  418. data/lib/hecks/bench/workload.rb +0 -137
  419. data/lib/hecks/bench.rb +0 -28
  420. data/lib/hecks/codemod.rb +0 -443
  421. data/lib/hecks/corpus.rb +0 -479
  422. data/lib/hecks/doc/reference.rb +0 -585
  423. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  424. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  425. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  426. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  427. data/lib/hecks/fuzzing/differential.rb +0 -192
  428. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  429. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  430. data/lib/hecks/fuzzing/form_census.rb +0 -285
  431. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  432. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  433. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  434. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  435. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  436. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  437. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  438. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  439. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  440. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  441. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  442. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  443. data/lib/hecks/fuzzing/properties.rb +0 -267
  444. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  445. data/lib/hecks/fuzzing/replay.rb +0 -930
  446. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  447. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  448. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  449. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  450. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  451. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  452. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  453. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  454. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  455. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  456. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  457. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  458. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  459. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  460. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  461. data/lib/hecks/fuzzing.rb +0 -23
  462. data/lib/hecks/grammar/evolve.rb +0 -524
  463. data/lib/hecks/query_ir.rb +0 -482
@@ -8,31 +8,35 @@ require_relative "../query_specification/field_path"
8
8
 
9
9
  module Hecks
10
10
  module Runtime
11
- # Interprets one declared `read_model`: resolves each of its
12
- # `include`d heads (root first, then the rest in dependency order),
13
- # matches non-root heads to already-projected rows by reference
14
- # field, and applies group_by/count/median reduction where declared.
15
- # Prefers a native SQLite escape hatch (#query_read_model) when the
16
- # model is simple enough for one; otherwise runs the whole join
17
- # in-process against loaded records.
11
+ # Interprets one declared `read_model`: resolves its `include`d heads and applies any
12
+ # `group_by` or reduction (`REDUCTION_WORD`), preferring a native SQLite path when it can.
18
13
  class ReadModelInterpreter
14
+ # The word each non-`count` reduction ivar reads back as in a refusal message.
15
+ REDUCTION_WORD = {
16
+ median_field: "median", sum_field: "sum", avg_field: "avg", min_field: "min",
17
+ max_field: "max", percentile_field: "percentile", any_field: "any", all_field: "all"
18
+ }.freeze
19
+
20
+ # `sum`/`avg` refuse a Float field (ADR 0078): summing floats cannot be made to agree,
21
+ # byte for byte, between Ruby and Rust, so v1 admits only the Integer case.
22
+ INTEGER_ONLY_REDUCTIONS = %i[sum_field avg_field].freeze
23
+ BOOLEAN_REDUCTIONS = %i[any_field all_field].freeze
24
+
19
25
  # @param registry [Runtime::Registry] the booted registry whose repositories
20
26
  # this interpreter reads
21
27
  def initialize(registry) = @registry = registry
22
28
 
23
29
  # Runs one declared read model and returns its projected rows.
24
- #
25
30
  # @param domain [String, Symbol] the domain the read model is declared in
26
31
  # @param model [Bluebook::ReadModel] the read model to run
27
- # @param args [Hash{Symbol => Object}] the query's declared arguments
28
- # @return [Array<Hash>] a one-element Array holding a Hash of head name to
29
- # projected rows (or a single row, for a non-`:many` head)
30
- # @raise [Runtime::TypeMismatch] if the reference argument is offered as a whole
31
- # object rather than a plain identity, or a `median` field is not numeric
32
+ # @param args [Hash] the query's declared arguments
33
+ # @return [Array<Hash>] a one-element array of head name => projected rows
34
+ # @raise [Runtime::TypeMismatch] if a reference is a whole object
32
35
  # @raise [Runtime::NotFound] if the reference argument names no record
33
36
  # @raise [KeyError] if a rooted read model is asked without its reference argument
34
- # @raise [ArgumentError] if `group_by` or `median` names a field its target
35
- # aggregate does not declare
37
+ # @raise [ArgumentError] if `group_by` or a reduction names an undeclared field, or one
38
+ # of the wrong type
39
+ # @raise [Runtime::InvariantViolation] if two rows collide on a full `group_by` key
36
40
  # @raise [Runtime::WiringError] if the aggregate's repository cannot be resolved
37
41
  def call(domain, model, args)
38
42
  project(domain, model, args)
@@ -40,15 +44,8 @@ module Hecks
40
44
 
41
45
  private
42
46
 
43
- # Root-first, then the SQLite escape hatch, then the join loop —
44
- # each step's own comment names a real, already-shipped bug the
45
- # current order fixes (the reference/TenantScope refusal ordering
46
- # above, the root-first head processing below). Splitting this
47
- # into smaller methods would scatter that ordering across method
48
- # boundaries where a future editor could silently break it, and
49
- # would force threading `rootless`/`reference_id`/`eligible`/
50
- # `projected`/`rows_by_as` between the pieces as parameters and
51
- # return values.
47
+ # Splitting this into smaller methods would scatter the root-first/SQLite/join
48
+ # ordering across boundaries where a future editor could silently break it.
52
49
  # rubocop:disable-next Metrics/AbcSize
53
50
  # rubocop:disable-next Metrics/CyclomaticComplexity
54
51
  # rubocop:disable-next Metrics/MethodLength
@@ -56,32 +53,19 @@ module Hecks
56
53
  def project(domain, model, args)
57
54
  bluebook = @registry.bluebook(domain)
58
55
  rootless = model.reference_target.nil?
59
- # Before the adapter early-return below, so the SQLite path inherits it.
60
- # Without this a stale caller passing a wrapped reference gets a
61
- # path-dependent answer — an adapter could quietly open the wrapped
62
- # reference while the in-process path reads it whole and finds nothing.
63
- # Refused up front, identically on every path. Nothing to refuse for
64
- # a rootless model — there's no reference argument to have offered
65
- # wrong at all.
56
+ # Refused before the adapter early-return, so both paths refuse identically
57
+ # rather than one silently opening a wrapped reference the other reads whole.
66
58
  refuse_object_reference(model, args) unless rootless
67
59
  reference_id = reference(args.fetch(model.reference_name)) unless rootless
68
- # Computed off the original model, before TenantScope wraps it — the
69
- # "which head(s) do options apply to" question is about what the
70
- # bluebook author declared, not about the synthetic tenant clause
71
- # the wrapper adds underneath. Plural (ADR 0055) — `on:` lets more
72
- # than one many-side head be eligible at once.
60
+ # Computed off the original model, before TenantScope wraps it, so this
61
+ # reflects what the bluebook author declared, not the synthetic tenant
62
+ # clause added underneath (ADR 0055; `on:` allows more than one head).
73
63
  eligible = model.filtered_head_names
74
64
  model = TenantScope.apply(model, args)
75
- # A rootless, `group_by`-declared, or `count`/`median`-declared
76
- # model skips the SQLite native escape hatch entirely (there is
77
- # no root aggregate to look up a repository for when rootless,
78
- # and `query_read_model` knows nothing about grouping or
79
- # reducing) — always runs the in-process loop below instead.
80
- # Correct everywhere ; not SQL-pushed-down for a SQLite-backed
81
- # aggregate yet, a real, named limit, not a silent one — the
82
- # same one `group_by` already accepted, extended here rather
83
- # than narrowed.
84
- unless rootless || model.group_by.any? || model.count? || model.median_field
65
+ # A rootless model, or one declaring `group_by` or any reduction, always runs
66
+ # the in-process loop below — none of those are pushed down into
67
+ # `query_read_model`, a known limit, not a silent one.
68
+ unless rootless || model.group_by.any? || model.reducing?
85
69
  repository = @registry.read_repository(domain, bluebook.aggregate(model.reference_target))
86
70
  if repository.respond_to?(:query_read_model) && repository.adapter.respond_to?(:query_read_model)
87
71
  return repository.query_read_model(domain, model, args,
@@ -89,22 +73,9 @@ module Hecks
89
73
  end
90
74
  end
91
75
 
92
- # **Root first, always** — regardless of `include` order in the
93
- # bluebook. `read_model_builder.rb`'s own `include` is
94
- # documented "Order-independent" (the `:many` flag is resolved
95
- # at build time, once `@reference_target` is known), but that
96
- # promise is not kept without this: running heads in
97
- # their literal declared order and matching each "many" head
98
- # against whatever was already in `projected` would leave it
99
- # empty, the very first time through, if a many-side head happened
100
- # to be declared before the root. A real, live bug (not a guess):
101
- # `include Promotion` before `include Item` on a read model
102
- # whose root is Item silently returned an empty array for
103
- # Promotion — no error, just a wrong, too-small answer — while
104
- # the reverse order worked purely by accident. `partition`,
105
- # not `sort_by`: Ruby's `sort_by` is not guaranteed stable,
106
- # and correctness here must not depend on it being so by
107
- # chance on the current MRI build.
76
+ # Root heads run first regardless of declared `include` order — a many-side
77
+ # head declared before its root would otherwise match against an empty
78
+ # `projected`. `partition`, not `sort_by`, which is not guaranteed stable.
108
79
  root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
109
80
  projected = []
110
81
  rows_by_as = {}
@@ -112,20 +83,9 @@ module Hecks
112
83
  rows = if head[:aggregate] == model.reference_target
113
84
  [fetch(bluebook, domain, head[:aggregate], reference_id)]
114
85
  elsif rootless
115
- # No root to FK-match against — a rootless model reads
116
- # each of its own heads whole, independently. Multiple
117
- # heads on one rootless model are never cross-joined
118
- # against each other, and there is no DSL to declare
119
- # one if you wanted to — `ReadModelBuilder#include_impl`
120
- # takes only `type`/`as:` (checked directly, not
121
- # assumed), and `group_by` groups this bulk read's own
122
- # output, it names no predicate between two heads.
123
- # Building a cross-join here would mean choosing a join
124
- # semantics (equality on which fields?) nobody has
125
- # declared — a real, deliberate scope limit pending a
126
- # future `include ..., joins: ...`-shaped grammar
127
- # addition (with its own Rust mirror), not a gap this
128
- # interpreter can quietly grow into on its own.
86
+ # A rootless model has no root to FK-match against, so each head
87
+ # reads independently; there is no DSL for cross-joining rootless
88
+ # heads together, a deliberate scope limit, not a gap to grow into.
129
89
  records(bluebook, domain, head[:aggregate])
130
90
  else
131
91
  matching(records(bluebook, domain, head[:aggregate])) do |record|
@@ -140,19 +100,19 @@ module Hecks
140
100
  projected << { aggregate: head[:aggregate], rows: rows }
141
101
  rows_by_as[head[:as]] = head[:many] ? rows : rows.first
142
102
  end
143
- # Declared order preserved in the output — only the
144
- # computation above needed reordering, not what a caller sees
145
- # back.
103
+ # Declared order is preserved in the output — only the computation above
104
+ # needed reordering.
146
105
  heads = model.aggregate_heads.to_h { |head| [head[:as], rows_by_as[head[:as]]] }
147
106
  grouped_head = group_by_target(model, bluebook)
148
107
  reduced_head = aggregation_target(model, bluebook)
149
108
  [heads.each_with_object({}) do |(as, value), out|
150
109
  out[as] = if grouped_head && as == grouped_head[:as]
151
- nest(value.map { |record| Value.materialize_unwrapped(row(record)) }, model.group_by_fields)
110
+ nest(value.map { |record| Value.materialize_unwrapped(row(record)) }, model.group_by_fields,
111
+ collision_check(model, bluebook, grouped_head))
152
112
  elsif reduced_head && as == reduced_head[:as] && model.count?
153
113
  value.length
154
114
  elsif reduced_head && as == reduced_head[:as]
155
- median(value, model.median_field)
115
+ reduce(model, value)
156
116
  elsif value.is_a?(Array)
157
117
  value.map { |record| Value.materialize(row(record)) }
158
118
  else
@@ -161,34 +121,8 @@ module Hecks
161
121
  end]
162
122
  end
163
123
 
164
- # **The root-first fix's own fix** — root-first alone only reaches one
165
- # level: it guarantees the root is in `projected` before any other
166
- # head is matched, but a chain of non-root heads (a head that
167
- # references another non-root head, not the root) is still
168
- # matched against whatever declaration order happened to put in
169
- # `projected` so far. `include Coupon` before `include Promotion`
170
- # on a read model rooted at Item, where Coupon references
171
- # Promotion (which references Item), silently returned an empty
172
- # `coupons` array — Coupon's match ran while `projected` held only
173
- # Item, one level short of what it needed.
174
- #
175
- # Fixed the same way root-first was: not by asking bluebook authors
176
- # to declare `include` in dependency order (the same promise
177
- # `read_model_builder.rb` already makes and this file is the one
178
- # place obligated to keep), but by topologically sorting the
179
- # non-root heads on their own declared reference fields before
180
- # this method's runtime matching ever runs — Kahn's algorithm,
181
- # picking ready heads in declared order at each step so declaring
182
- # order still governs whenever there is no dependency to break a
183
- # tie. This generalizes root-first (a chain of length 1) to a
184
- # chain of any depth, and to a head depending on more than one
185
- # other head at once (not just a straight chain).
186
- #
187
- # A cycle among non-root heads (A references B which references A)
188
- # has no valid topological order at all — falls back to the
189
- # remaining heads' declared order rather than looping forever, the
190
- # same "whichever runs first finds nothing" behaviour this method
191
- # had for every non-root head before root-first existed.
124
+ # Topologically sorts non-root heads by their declared reference fields
125
+ # (Kahn's algorithm); a cycle falls back to declared order rather than looping.
192
126
  def order_other_heads(bluebook, root_heads, other_heads)
193
127
  resolved = root_heads.map { |head| head[:aggregate] }
194
128
  remaining = other_heads.dup
@@ -208,25 +142,8 @@ module Hecks
208
142
  ordered
209
143
  end
210
144
 
211
- # Which other declared (non-root) heads a head's own aggregate
212
- # holds a reference field toward — the same relationship this
213
- # file's runtime matching checks record-by-record, asked here
214
- # statically, once, to order heads before any record is read.
215
- #
216
- # `head[:aggregate]` names whatever `include` was given — and
217
- # `include` accepts a nested entity (Member, nested under
218
- # ValueObject ; Handler and Dispatch, nested under ProcessManager
219
- # — bluebook.bluebook's own `WholeBluebook` read model includes
220
- # all three) just as readily as a top-level aggregate.
221
- # `bluebook.aggregate` only ever finds the latter
222
- # (Behaviour::Chapter#aggregate searches `@aggregates`, which
223
- # holds no entities), so it returns nil for an entity-headed
224
- # include — a real case, not a malformed one. `records`, below,
225
- # already treats that nil as "no rows of its own to fetch" ;
226
- # a head with no rows of its own has nothing to check for a
227
- # reference field either, so it depends on nothing here, the
228
- # same as it always silently read empty before this file's
229
- # topological sort existed.
145
+ # Which other declared heads a head's own aggregate holds a reference field
146
+ # toward; an entity-headed include resolves no aggregate and so depends on nothing.
230
147
  def depends_on(bluebook, head, other_heads)
231
148
  aggregate = bluebook.aggregate(head[:aggregate])
232
149
  return [] unless aggregate
@@ -236,11 +153,8 @@ module Hecks
236
153
  .map { |other| other[:aggregate] }
237
154
  end
238
155
 
239
- # `group_by`'s own declared fields, checked against the one
240
- # many-side head they apply to (`seal_group_by` already refuses
241
- # zero or several) — resolved here, once, rather than re-derived
242
- # per row. Raises loudly on a typo'd field name rather than
243
- # silently grouping every row into one bucket keyed `nil`.
156
+ # Resolves `group_by`'s target head and validates its fields once, raising on
157
+ # a typo'd field name rather than silently grouping every row under `nil`.
244
158
  def group_by_target(model, bluebook)
245
159
  return nil unless model.group_by.any?
246
160
 
@@ -248,16 +162,9 @@ module Hecks
248
162
  aggregate = bluebook.aggregate(target[:aggregate])
249
163
  model.group_by_fields.each do |field|
250
164
  next if aggregate.attribute(field)
251
- # The lifecycle field is a field, and refusing it here was a drift
252
- # between two halves of the same language: `where(status: "logged")`
253
- # has always been legal on the same aggregate, because a lifecycle
254
- # state is stored on the record like anything else — it is simply
255
- # declared by `lifecycle :status` rather than by `attribute`.
256
- #
257
- # It is also the grouping anybody actually wants. "How are we doing"
258
- # over a bug ledger is the count per status, and a report that could
259
- # group by every field except that one could not answer the question
260
- # reports exist for.
165
+ # A lifecycle field is a real field too — it's stored on the record like
166
+ # any attribute, just declared via `lifecycle :status`, and it's usually
167
+ # exactly the field a report wants to group by.
261
168
  next if aggregate.lifecycle && aggregate.lifecycle.field.to_sym == field.to_sym
262
169
 
263
170
  raise ArgumentError,
@@ -267,77 +174,113 @@ module Hecks
267
174
  target
268
175
  end
269
176
 
270
- # One level of nesting per field, in `group_by`'s own declared
271
- # order — the leaf is the row with every grouped field removed
272
- # (already spent, as the keys that reached it). Assumes the full
273
- # `group_by` path uniquely identifies one row (true for grouping by
274
- # an aggregate's own full identity, ConsoleSettings' own real use)
275
- # — `leaves.first` silently keeps only the first row when several
276
- # share the same full key path. A real, deliberate scope limit:
277
- # true multi-row-per-leaf grouping would change the leaf shape
278
- # from "one row" to "an array of rows", which no caller needs yet.
279
- def nest(rows, fields)
177
+ # Whether `group_by` leaves must hold one row, decided from the declaration
178
+ # alone (ADR 0061 D1): nil when the key path already covers the identity.
179
+ def collision_check(model, bluebook, grouped_head)
180
+ model.groups_by_identity?(bluebook.aggregate(grouped_head[:aggregate])) ? nil : model
181
+ end
182
+
183
+ # Nests one level per `group_by` field; a leaf holding more than one row
184
+ # is a `group_by` collision (ADR 0061 D1), refused rather than picking one.
185
+ def nest(rows, fields, checked, reached = [])
280
186
  field, *rest = fields
281
- rows.group_by { |row| row[field] }.transform_values do |group|
187
+ rows.group_by { |row| row[field] }.to_h do |key, group|
282
188
  # Strip only the field just grouped by, not the whole remaining
283
189
  # list — `rest`'s own fields have to survive into the recursive
284
- # call below, or the next level groups by a key that's already
285
- # gone (found by trying it: a two-field group_by's own second
286
- # level came back keyed `nil` for every group, every time).
287
- stripped = group.map { |row| row.reject { |key, _| key == field } }
288
- rest.empty? ? stripped.first : nest(stripped, rest)
190
+ # call below, or the next level groups by a key that's already gone.
191
+ stripped = group.map { |row| row.reject { |name, _| name == field } }
192
+ path = reached + [[field, key]]
193
+ [key, rest.empty? ? leaf(stripped, checked, path) : nest(stripped, rest, checked, path)]
289
194
  end
290
195
  end
291
196
 
292
- # `count`/`median`'s own declared target — the same single
293
- # many-side head `group_by_target` resolves, for the same reason
294
- # (`seal_aggregation` already refuses zero or several many-side
295
- # heads, and refuses count/median declared alongside group_by, so
296
- # there is exactly one to name whenever this is asked at all).
297
- # Raises loudly on a `median` field that doesn't exist, or exists
298
- # but isn't numeric, rather than silently comparing garbage or
299
- # averaging strings.
197
+ def leaf(rows, checked, path)
198
+ return rows.first unless checked && rows.size > 1
199
+
200
+ raise InvariantViolation,
201
+ RefusalWording.render_site("InvariantViolation", "group_by_collision",
202
+ read_model: checked.name, fields: checked.group_by_fields,
203
+ ids: rows.map { |row| row[:id] },
204
+ key: path.map { |field, value| "#{field} = #{value}" }.join(", "))
205
+ end
206
+
207
+ # Resolves a reduction's single many-side head, raising on a field that doesn't
208
+ # exist or has the wrong type rather than comparing garbage.
300
209
  def aggregation_target(model, bluebook)
301
- return nil unless model.count? || model.median_field
210
+ return nil unless model.reducing?
302
211
 
303
212
  target = model.aggregate_heads.find { |head| head[:many] }
304
- return target unless model.median_field
213
+ return target if model.count?
305
214
 
215
+ ivar, word = REDUCTION_WORD.find { |name, _| model.public_send(name) }
216
+ field = model.public_send(ivar)
306
217
  aggregate = bluebook.aggregate(target[:aggregate])
307
- attribute = aggregate.attribute(model.median_field)
218
+ attribute = aggregate.attribute(field)
308
219
  unless attribute
309
220
  raise ArgumentError,
310
- "#{model.name}'s median names #{model.median_field.inspect}, but #{target[:aggregate]} " \
221
+ "#{model.name}'s #{word} names #{field.inspect}, but #{target[:aggregate]} " \
311
222
  "declares no such attribute (it declares #{aggregate.attributes.map(&:name).join(', ')})"
312
223
  end
313
- unless QuerySpecification::FieldPath.numeric?(attribute, []) { |type| aggregate.value_object(type) }
314
- raise ArgumentError,
315
- "#{model.name}'s median names #{model.median_field.inspect} on #{target[:aggregate]}, " \
316
- "which is not numeric — median needs a numeric field (a bare number, or a " \
317
- "value object carrying one)"
318
- end
224
+ validate_reduction_type!(model, word, field, target[:aggregate], aggregate, attribute, ivar)
319
225
  target
320
226
  end
321
227
 
322
- # **The standard definition**. An odd count's median is its one true
323
- # middle value, sorted ; an even count's median is the average of
324
- # its two middle values — the common convention (as opposed to,
325
- # say, always taking the lower of the two), and the one this
326
- # session's own task named explicitly as the deliberate choice.
327
- # An empty collection has no median: nil, not zero, so a caller
328
- # cannot mistake "nothing to average" for "the values averaged to
329
- # zero". Reuses `Ports::Query::InMemory`'s own field reading
330
- # (`FieldPath.dig` + `comparable`) — the same unwrap `where`/
331
- # `order_by` already give a value-object-carrying field, so
332
- # `median :daily_limit` on a `DailyLimit{cents}` field reads the
333
- # same scalar an `order_by :daily_limit` comparison already would.
334
- def median(rows, field)
335
- values = rows.map { |record| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row(record), field)) }
336
- .compact.sort
228
+ # @raise [ArgumentError] if `attribute` does not have the type `ivar`'s reduction needs
229
+ def validate_reduction_type!(model, word, field, aggregate_name, aggregate, attribute, ivar)
230
+ wrap = ->(type) { aggregate.value_object(type) }
231
+ ok = if BOOLEAN_REDUCTIONS.include?(ivar)
232
+ QuerySpecification::FieldPath.boolean?(attribute, [], &wrap)
233
+ elsif INTEGER_ONLY_REDUCTIONS.include?(ivar)
234
+ QuerySpecification::FieldPath.integer?(attribute, [], &wrap)
235
+ else
236
+ QuerySpecification::FieldPath.numeric?(attribute, [], &wrap)
237
+ end
238
+ return if ok
239
+
240
+ kind = BOOLEAN_REDUCTIONS.include?(ivar) ? "boolean" : "numeric"
241
+ raise ArgumentError,
242
+ "#{model.name}'s #{word} names #{field.inspect} on #{aggregate_name}, " \
243
+ "which is not #{kind} — #{word} needs a #{kind == 'boolean' ? 'true/false' : 'numeric'} field"
244
+ end
245
+
246
+ # Dispatches to the one reduction `model` declares, over the eligible collection's own
247
+ # `comparable`-mapped values (or raw booleans for `any`/`all`) — the interpretation of
248
+ # `Runtime::ReadModelInterpreter#project`'s `reduced_head` branch.
249
+ def reduce(model, rows)
250
+ return boolean_values(rows, model.any_field).any? if model.any_field
251
+ return boolean_values(rows, model.all_field).all? if model.all_field
252
+ return numeric_values(rows, model.sum_field).sum if model.sum_field
253
+ return average(numeric_values(rows, model.avg_field)) if model.avg_field
254
+ return numeric_values(rows, model.min_field).min if model.min_field
255
+ return numeric_values(rows, model.max_field).max if model.max_field
256
+ return percentile(numeric_values(rows, model.percentile_field), model.percentile_at) if model.percentile_field
257
+
258
+ percentile(numeric_values(rows, model.median_field), 0.5)
259
+ end
260
+
261
+ # Shared by every reduction: `comparable` unwraps a value-object-wrapped field down to
262
+ # its sole member (numeric or boolean alike) exactly as `where`/`order_by` already read
263
+ # it, so `flagged: { value: true }` reduces on the boolean, not the ever-truthy Hash.
264
+ def reduction_values(rows, field)
265
+ # `compact`, not `filter_map`: a stored `false` is a real value, not an absent one.
266
+ rows.map { |record| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row(record), field)) }.compact
267
+ end
268
+ alias numeric_values reduction_values
269
+ alias boolean_values reduction_values
270
+
271
+ # `nil` on no rows: a rate of nothing is undefined, not zero.
272
+ def average(values) = values.empty? ? nil : values.sum.to_f / values.length
273
+
274
+ # The value at one interpolated rank (`0.0`..`1.0`); `at: 0.5` is the standard median
275
+ # (middle value when odd, average of the two middle values when even). `nil` on no rows.
276
+ def percentile(values, at)
337
277
  return nil if values.empty?
338
278
 
339
- middle = values.length / 2
340
- values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
279
+ sorted = values.sort
280
+ position = at * (sorted.length - 1)
281
+ lower = position.floor
282
+ fraction = position - lower
283
+ fraction.zero? ? sorted[lower] : sorted[lower] + (fraction * (sorted[lower + 1] - sorted[lower]))
341
284
  end
342
285
 
343
286
  def fetch(bluebook, domain, aggregate_name, id)
@@ -351,20 +294,8 @@ module Hecks
351
294
  aggregate ? @registry.read_repository(domain, aggregate).all : []
352
295
  end
353
296
 
354
- # A stored reference holds the target's id inside the reference
355
- # attribute's own declared shape, so reading it is reading that shape —
356
- # a different thing from the identity unwrap that was removed. An
357
- # identity is declared as a path and followed (Runtime::Identity) ; a
358
- # reference has no path of its own, and `Value.scalar` refuses a
359
- # composite rather than guessing which field was meant.
360
- #
361
- # Storing the scalar itself would remove this reading altogether. That
362
- # is a change to how references are stored, not to how identities are
363
- # declared, so it is not made here.
364
- # An ask names itself where a command would name itself, and says the
365
- # same thing about the same shape. `Value.refuse_object_reference` is
366
- # not reused because it speaks of a command and its attribute ; a read
367
- # model has a query name and one declared reference.
297
+ # A reference has no path of its own (unlike an identity), so `Value.scalar`
298
+ # refuses a composite rather than guessing which field was meant.
368
299
  def refuse_object_reference(model, args)
369
300
  offered = args.fetch(model.reference_name, nil)
370
301
  return unless offered.is_a?(Hash) || offered.is_a?(Value)
@@ -1,37 +1,12 @@
1
1
  module Hecks
2
2
  module Runtime
3
- # The out-of-band half of `projects` (S12, ADR 0025 — "Consistency
4
- # across aggregate boundaries"). A projected field is never written
5
- # by the command that reads it — nothing at dispatch time takes a
6
- # live cross-aggregate read the way `CommandRules::References
7
- # #dereference` still does — so this is the one place a projected
8
- # field's value actually gets copied over: walk every record of the
9
- # owning aggregate, resolve each of its own `projected_fields`
10
- # through the reference it names, and `save` the local copy.
11
- #
12
- # **Explicit and callable, not automatic** — no on-boot detection of a
13
- # freshly-declared `projects` with no held-era precedent, no
14
- # generated `Policy#for_each` reaction keeping it live in real
15
- # time as the target changes. Both are real extensions this same
16
- # ADR section describes; both are deliberately deferred. This is
17
- # the mechanism a caller reaches for by hand — `bin/rebuild
18
- # Domain::Aggregate`, a scheduled job, whatever the deployment
19
- # needs — proven to work end to end before either automatic
20
- # trigger is built on top of it.
21
- #
22
- # **Needs no new adapter capability**. `find`/`all`/`save` are the same
23
- # three primitives every real adapter already answers identically
24
- # (`Ports::Persistence::AppendOnly#save` — append, then project,
25
- # the same for Memory/Postgres/SQLite/D1/Heki) — confirmed by
26
- # direct reading before this was built, not assumed.
3
+ # Copies `projects` field values from their referenced records into the owning
4
+ # aggregate's records; called explicitly, never at dispatch time (ADR 0025).
27
5
  module RebuildSweep
28
6
  module_function
29
7
 
30
- # One aggregate's own projected fields, refreshed across every
31
- # record it holds. Returns how many records actually changed —
32
- # `save` only runs when a projected value would differ from what
33
- # is already stored, so re-running a sweep with nothing having
34
- # moved on the target side touches the append log not at all.
8
+ # Refreshes one aggregate's projected fields across every record it holds.
9
+ # Saves only records whose projected value differs from what is stored.
35
10
  #
36
11
  # @param registry [Runtime::Registry] the booted registry to read repositories from
37
12
  # @param domain [String] the domain the aggregate belongs to
@@ -45,16 +20,9 @@ module Hecks
45
20
  repository.all.count { |record| refresh(registry, domain, aggregate, record, repository) }
46
21
  end
47
22
 
48
- # Refreshes one record's own projected fields in place, saving it if any changed.
23
+ # Refreshes one record's projected fields in place, saving it if any changed.
49
24
  #
50
- # @param registry [Runtime::Registry] the booted registry to read the target's
51
- # repository from
52
- # @param domain [String] the domain the aggregate belongs to
53
- # @param aggregate [Bluebook::Aggregate] the aggregate `record` is an instance of
54
- # @param record [Runtime::Instance] the record to refresh, mutated in place
55
- # @param repository [Ports::Persistence::AppendOnly] the repository to save `record`
56
- # through when it changes
57
- # @return [Boolean] true if any projected field's value changed and `record` was saved
25
+ # @return [Boolean] true if `record` was saved
58
26
  def refresh(registry, domain, aggregate, record, repository)
59
27
  changed = false
60
28
 
@@ -71,22 +39,8 @@ module Hecks
71
39
  changed
72
40
  end
73
41
 
74
- # Reads the current value of one projected field's remote target field.
75
- #
76
- # `nil` when the reference itself does not resolve in this
77
- # chapter (a cross-domain target left "unfollowed" the same way
78
- # References#dereference already leaves one) or when the record
79
- # names no target at all — an optional reference nobody set.
80
- #
81
- # @param registry [Runtime::Registry] the booted registry to read the target's
82
- # repository from
83
- # @param domain [String] the domain the aggregate belongs to
84
- # @param aggregate [Bluebook::Aggregate] the aggregate declaring `field`
85
- # @param record [Runtime::Instance] the record holding the reference to follow
86
- # @param field [Bluebook::ProjectedField] the projected field to resolve
87
- # @return [Object, nil] the target record's own `field.remote_field` value; nil if the
88
- # reference type does not resolve, the record names no target, or the target record
89
- # cannot be found
42
+ # Reads the current value of a projected field's remote target field.
43
+ # Nil when the reference does not resolve here, names no target, or finds no record.
90
44
  def remote_value(registry, domain, aggregate, record, field)
91
45
  target = aggregate.attribute(field.reference)&.type&.resolve
92
46
  return nil unless target