hecks 2.8.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 (404) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +4 -6
  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 +17 -98
  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 +5 -48
  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 +31 -141
  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 +14 -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 +25 -31
  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 +16 -69
  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 -194
  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 -111
  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 +53 -267
  109. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +43 -305
  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 +6 -21
  114. data/lib/hecks/bluebook/model_check.rb +100 -597
  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 +4 -23
  128. data/lib/hecks/cli/document.rb +3 -21
  129. data/lib/hecks/cli/ir.rb +3 -5
  130. data/lib/hecks/cli/mcp.rb +2 -3
  131. data/lib/hecks/cli/mcp_door.rb +19 -114
  132. data/lib/hecks/cli/model_check.rb +18 -85
  133. data/lib/hecks/cli/project_cli.rb +7 -40
  134. data/lib/hecks/cli/project_diagrams.rb +0 -7
  135. data/lib/hecks/cli/run.rb +12 -58
  136. data/lib/hecks/cli/smoke_test.rb +2 -16
  137. data/lib/hecks/cli/stores.rb +1 -2
  138. data/lib/hecks/cli.rb +18 -24
  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 +82 -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 +9 -90
  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 +61 -64
  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 +6 -25
  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 +18 -124
  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 +150 -678
  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 +2 -15
  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 +42 -134
  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 +3 -6
  336. data/lib/hecks/release/runner/commands.rb +2 -4
  337. data/lib/hecks/release/runner/gem_publisher.rb +2 -3
  338. data/lib/hecks/release/runner/npm_publisher.rb +6 -20
  339. data/lib/hecks/release/runner/preflight.rb +4 -19
  340. data/lib/hecks/release/runner/tagger.rb +2 -9
  341. data/lib/hecks/release/runner.rb +3 -32
  342. data/lib/hecks/rendering.rb +4 -26
  343. data/lib/hecks/router/namespace_installer.rb +12 -36
  344. data/lib/hecks/router.rb +4 -8
  345. data/lib/hecks/runtime/aggregate_lock.rb +8 -33
  346. data/lib/hecks/runtime/boot_gates.rb +3 -18
  347. data/lib/hecks/runtime/caller.rb +19 -59
  348. data/lib/hecks/runtime/capability_graph.rb +5 -21
  349. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +16 -78
  350. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +23 -160
  351. data/lib/hecks/runtime/command_interpreter.rb +124 -306
  352. data/lib/hecks/runtime/command_rules/admissibility.rb +95 -366
  353. data/lib/hecks/runtime/command_rules/arithmetic.rb +66 -196
  354. data/lib/hecks/runtime/command_rules/authorization.rb +13 -45
  355. data/lib/hecks/runtime/command_rules/emission.rb +8 -21
  356. data/lib/hecks/runtime/command_rules/references.rb +23 -202
  357. data/lib/hecks/runtime/dependency_planning.rb +30 -102
  358. data/lib/hecks/runtime/dispatcher.rb +82 -241
  359. data/lib/hecks/runtime/entity_element.rb +40 -462
  360. data/lib/hecks/runtime/entity_interpreter.rb +64 -219
  361. data/lib/hecks/runtime/errors.rb +32 -115
  362. data/lib/hecks/runtime/event.rb +3 -25
  363. data/lib/hecks/runtime/identity.rb +18 -154
  364. data/lib/hecks/runtime/instance.rb +18 -150
  365. data/lib/hecks/runtime/interpreting.rb +11 -70
  366. data/lib/hecks/runtime/invocation.rb +28 -164
  367. data/lib/hecks/runtime/loader.rb +48 -186
  368. data/lib/hecks/runtime/outbox.rb +38 -261
  369. data/lib/hecks/runtime/policy_interpreter.rb +38 -200
  370. data/lib/hecks/runtime/port_operation_interpreter.rb +26 -87
  371. data/lib/hecks/runtime/query_interpreter.rb +45 -187
  372. data/lib/hecks/runtime/reaction_invocation.rb +36 -138
  373. data/lib/hecks/runtime/read_model_interpreter.rb +117 -208
  374. data/lib/hecks/runtime/rebuild_sweep.rb +8 -54
  375. data/lib/hecks/runtime/reference_hop.rb +8 -81
  376. data/lib/hecks/runtime/refusal_wording.rb +8 -89
  377. data/lib/hecks/runtime/registry/saga_persistence.rb +10 -102
  378. data/lib/hecks/runtime/registry/verification.rb +52 -225
  379. data/lib/hecks/runtime/registry/world_defaults.rb +4 -53
  380. data/lib/hecks/runtime/registry.rb +99 -244
  381. data/lib/hecks/runtime/remote_dispatcher.rb +37 -152
  382. data/lib/hecks/runtime/routing.rb +4 -15
  383. data/lib/hecks/runtime/saga_interpreter/correlation.rb +14 -61
  384. data/lib/hecks/runtime/saga_interpreter.rb +79 -263
  385. data/lib/hecks/runtime/saga_pending_dispatch.rb +2 -43
  386. data/lib/hecks/runtime/tenant_check.rb +6 -68
  387. data/lib/hecks/runtime/tenant_scope.rb +3 -28
  388. data/lib/hecks/runtime/value/admission.rb +16 -67
  389. data/lib/hecks/runtime/value/coercion.rb +155 -641
  390. data/lib/hecks/runtime/value/entity_list_coercion.rb +26 -219
  391. data/lib/hecks/runtime/value.rb +12 -84
  392. data/lib/hecks/runtime.rb +17 -65
  393. data/lib/hecks/storehouse.rb +141 -568
  394. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +8 -32
  395. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +6 -23
  396. data/lib/hecks/vendoring/git_environment.rb +2 -8
  397. data/lib/hecks/vendoring/git_source.rb +4 -10
  398. data/lib/hecks/vendoring.rb +7 -46
  399. data/lib/hecks/version.rb +3 -14
  400. data/lib/hecks.rb +26 -81
  401. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +8 -46
  402. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +11 -80
  403. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +10 -99
  404. metadata +3 -2
@@ -8,33 +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
36
- # @raise [Runtime::InvariantViolation] if two rows reach the same full `group_by`
37
- # key path and that path does not cover the grouped aggregate's whole identity
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
38
40
  # @raise [Runtime::WiringError] if the aggregate's repository cannot be resolved
39
41
  def call(domain, model, args)
40
42
  project(domain, model, args)
@@ -42,15 +44,8 @@ module Hecks
42
44
 
43
45
  private
44
46
 
45
- # Root-first, then the SQLite escape hatch, then the join loop —
46
- # each step's own comment names a real, already-shipped bug the
47
- # current order fixes (the reference/TenantScope refusal ordering
48
- # above, the root-first head processing below). Splitting this
49
- # into smaller methods would scatter that ordering across method
50
- # boundaries where a future editor could silently break it, and
51
- # would force threading `rootless`/`reference_id`/`eligible`/
52
- # `projected`/`rows_by_as` between the pieces as parameters and
53
- # 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.
54
49
  # rubocop:disable-next Metrics/AbcSize
55
50
  # rubocop:disable-next Metrics/CyclomaticComplexity
56
51
  # rubocop:disable-next Metrics/MethodLength
@@ -58,32 +53,19 @@ module Hecks
58
53
  def project(domain, model, args)
59
54
  bluebook = @registry.bluebook(domain)
60
55
  rootless = model.reference_target.nil?
61
- # Before the adapter early-return below, so the SQLite path inherits it.
62
- # Without this a stale caller passing a wrapped reference gets a
63
- # path-dependent answer — an adapter could quietly open the wrapped
64
- # reference while the in-process path reads it whole and finds nothing.
65
- # Refused up front, identically on every path. Nothing to refuse for
66
- # a rootless model — there's no reference argument to have offered
67
- # 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.
68
58
  refuse_object_reference(model, args) unless rootless
69
59
  reference_id = reference(args.fetch(model.reference_name)) unless rootless
70
- # Computed off the original model, before TenantScope wraps it — the
71
- # "which head(s) do options apply to" question is about what the
72
- # bluebook author declared, not about the synthetic tenant clause
73
- # the wrapper adds underneath. Plural (ADR 0055) — `on:` lets more
74
- # 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).
75
63
  eligible = model.filtered_head_names
76
64
  model = TenantScope.apply(model, args)
77
- # A rootless, `group_by`-declared, or `count`/`median`-declared
78
- # model skips the SQLite native escape hatch entirely (there is
79
- # no root aggregate to look up a repository for when rootless,
80
- # and `query_read_model` knows nothing about grouping or
81
- # reducing) — always runs the in-process loop below instead.
82
- # Correct everywhere ; not SQL-pushed-down for a SQLite-backed
83
- # aggregate yet, a real, named limit, not a silent one — the
84
- # same one `group_by` already accepted, extended here rather
85
- # than narrowed.
86
- 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?
87
69
  repository = @registry.read_repository(domain, bluebook.aggregate(model.reference_target))
88
70
  if repository.respond_to?(:query_read_model) && repository.adapter.respond_to?(:query_read_model)
89
71
  return repository.query_read_model(domain, model, args,
@@ -91,22 +73,9 @@ module Hecks
91
73
  end
92
74
  end
93
75
 
94
- # **Root first, always** — regardless of `include` order in the
95
- # bluebook. `read_model_builder.rb`'s own `include` is
96
- # documented "Order-independent" (the `:many` flag is resolved
97
- # at build time, once `@reference_target` is known), but that
98
- # promise is not kept without this: running heads in
99
- # their literal declared order and matching each "many" head
100
- # against whatever was already in `projected` would leave it
101
- # empty, the very first time through, if a many-side head happened
102
- # to be declared before the root. A real, live bug (not a guess):
103
- # `include Promotion` before `include Item` on a read model
104
- # whose root is Item silently returned an empty array for
105
- # Promotion — no error, just a wrong, too-small answer — while
106
- # the reverse order worked purely by accident. `partition`,
107
- # not `sort_by`: Ruby's `sort_by` is not guaranteed stable,
108
- # and correctness here must not depend on it being so by
109
- # 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.
110
79
  root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
111
80
  projected = []
112
81
  rows_by_as = {}
@@ -114,20 +83,9 @@ module Hecks
114
83
  rows = if head[:aggregate] == model.reference_target
115
84
  [fetch(bluebook, domain, head[:aggregate], reference_id)]
116
85
  elsif rootless
117
- # No root to FK-match against — a rootless model reads
118
- # each of its own heads whole, independently. Multiple
119
- # heads on one rootless model are never cross-joined
120
- # against each other, and there is no DSL to declare
121
- # one if you wanted to — `ReadModelBuilder#include_impl`
122
- # takes only `type`/`as:` (checked directly, not
123
- # assumed), and `group_by` groups this bulk read's own
124
- # output, it names no predicate between two heads.
125
- # Building a cross-join here would mean choosing a join
126
- # semantics (equality on which fields?) nobody has
127
- # declared — a real, deliberate scope limit pending a
128
- # future `include ..., joins: ...`-shaped grammar
129
- # addition (with its own Rust mirror), not a gap this
130
- # 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.
131
89
  records(bluebook, domain, head[:aggregate])
132
90
  else
133
91
  matching(records(bluebook, domain, head[:aggregate])) do |record|
@@ -142,9 +100,8 @@ module Hecks
142
100
  projected << { aggregate: head[:aggregate], rows: rows }
143
101
  rows_by_as[head[:as]] = head[:many] ? rows : rows.first
144
102
  end
145
- # Declared order preserved in the output — only the
146
- # computation above needed reordering, not what a caller sees
147
- # back.
103
+ # Declared order is preserved in the output — only the computation above
104
+ # needed reordering.
148
105
  heads = model.aggregate_heads.to_h { |head| [head[:as], rows_by_as[head[:as]]] }
149
106
  grouped_head = group_by_target(model, bluebook)
150
107
  reduced_head = aggregation_target(model, bluebook)
@@ -155,7 +112,7 @@ module Hecks
155
112
  elsif reduced_head && as == reduced_head[:as] && model.count?
156
113
  value.length
157
114
  elsif reduced_head && as == reduced_head[:as]
158
- median(value, model.median_field)
115
+ reduce(model, value)
159
116
  elsif value.is_a?(Array)
160
117
  value.map { |record| Value.materialize(row(record)) }
161
118
  else
@@ -164,34 +121,8 @@ module Hecks
164
121
  end]
165
122
  end
166
123
 
167
- # **The root-first fix's own fix** — root-first alone only reaches one
168
- # level: it guarantees the root is in `projected` before any other
169
- # head is matched, but a chain of non-root heads (a head that
170
- # references another non-root head, not the root) is still
171
- # matched against whatever declaration order happened to put in
172
- # `projected` so far. `include Coupon` before `include Promotion`
173
- # on a read model rooted at Item, where Coupon references
174
- # Promotion (which references Item), silently returned an empty
175
- # `coupons` array — Coupon's match ran while `projected` held only
176
- # Item, one level short of what it needed.
177
- #
178
- # Fixed the same way root-first was: not by asking bluebook authors
179
- # to declare `include` in dependency order (the same promise
180
- # `read_model_builder.rb` already makes and this file is the one
181
- # place obligated to keep), but by topologically sorting the
182
- # non-root heads on their own declared reference fields before
183
- # this method's runtime matching ever runs — Kahn's algorithm,
184
- # picking ready heads in declared order at each step so declaring
185
- # order still governs whenever there is no dependency to break a
186
- # tie. This generalizes root-first (a chain of length 1) to a
187
- # chain of any depth, and to a head depending on more than one
188
- # other head at once (not just a straight chain).
189
- #
190
- # A cycle among non-root heads (A references B which references A)
191
- # has no valid topological order at all — falls back to the
192
- # remaining heads' declared order rather than looping forever, the
193
- # same "whichever runs first finds nothing" behaviour this method
194
- # 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.
195
126
  def order_other_heads(bluebook, root_heads, other_heads)
196
127
  resolved = root_heads.map { |head| head[:aggregate] }
197
128
  remaining = other_heads.dup
@@ -211,25 +142,8 @@ module Hecks
211
142
  ordered
212
143
  end
213
144
 
214
- # Which other declared (non-root) heads a head's own aggregate
215
- # holds a reference field toward — the same relationship this
216
- # file's runtime matching checks record-by-record, asked here
217
- # statically, once, to order heads before any record is read.
218
- #
219
- # `head[:aggregate]` names whatever `include` was given — and
220
- # `include` accepts a nested entity (Member, nested under
221
- # ValueObject ; Handler and Dispatch, nested under ProcessManager
222
- # — bluebook.bluebook's own `WholeBluebook` read model includes
223
- # all three) just as readily as a top-level aggregate.
224
- # `bluebook.aggregate` only ever finds the latter
225
- # (Behaviour::Chapter#aggregate searches `@aggregates`, which
226
- # holds no entities), so it returns nil for an entity-headed
227
- # include — a real case, not a malformed one. `records`, below,
228
- # already treats that nil as "no rows of its own to fetch" ;
229
- # a head with no rows of its own has nothing to check for a
230
- # reference field either, so it depends on nothing here, the
231
- # same as it always silently read empty before this file's
232
- # 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.
233
147
  def depends_on(bluebook, head, other_heads)
234
148
  aggregate = bluebook.aggregate(head[:aggregate])
235
149
  return [] unless aggregate
@@ -239,11 +153,8 @@ module Hecks
239
153
  .map { |other| other[:aggregate] }
240
154
  end
241
155
 
242
- # `group_by`'s own declared fields, checked against the one
243
- # many-side head they apply to (`seal_group_by` already refuses
244
- # zero or several) — resolved here, once, rather than re-derived
245
- # per row. Raises loudly on a typo'd field name rather than
246
- # 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`.
247
158
  def group_by_target(model, bluebook)
248
159
  return nil unless model.group_by.any?
249
160
 
@@ -251,16 +162,9 @@ module Hecks
251
162
  aggregate = bluebook.aggregate(target[:aggregate])
252
163
  model.group_by_fields.each do |field|
253
164
  next if aggregate.attribute(field)
254
- # The lifecycle field is a field, and refusing it here was a drift
255
- # between two halves of the same language: `where(status: "logged")`
256
- # has always been legal on the same aggregate, because a lifecycle
257
- # state is stored on the record like anything else — it is simply
258
- # declared by `lifecycle :status` rather than by `attribute`.
259
- #
260
- # It is also the grouping anybody actually wants. "How are we doing"
261
- # over a bug ledger is the count per status, and a report that could
262
- # group by every field except that one could not answer the question
263
- # 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.
264
168
  next if aggregate.lifecycle && aggregate.lifecycle.field.to_sym == field.to_sym
265
169
 
266
170
  raise ArgumentError,
@@ -270,25 +174,14 @@ module Hecks
270
174
  target
271
175
  end
272
176
 
273
- # The read model whose `group_by` leaves must each hold one row, or nil
274
- # when its key path covers the grouped aggregate's whole identity and so
275
- # cannot collide (ADR 0061, decision D1). Decided from the declaration,
276
- # never from the rows.
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.
277
179
  def collision_check(model, bluebook, grouped_head)
278
180
  model.groups_by_identity?(bluebook.aggregate(grouped_head[:aggregate])) ? nil : model
279
181
  end
280
182
 
281
- # One level of nesting per field, in `group_by`'s own declared
282
- # order — the leaf is the row with every grouped field removed
283
- # (already spent, as the keys that reached it). A leaf holds one row:
284
- # when `checked` names a read model and two rows reach the same full
285
- # key path, this refuses rather than keep one of them (ADR 0061,
286
- # decision D1). An array-of-rows leaf would change the output shape
287
- # of every shipped `group_by`, so it is not offered here.
288
- #
289
- # Groups are walked in first-occurrence order and depth first, so the
290
- # collision reported is the first one reached; the Rust kernel's
291
- # `read_model::nest` walks in the same order.
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.
292
185
  def nest(rows, fields, checked, reached = [])
293
186
  field, *rest = fields
294
187
  rows.group_by { |row| row[field] }.to_h do |key, group|
@@ -311,55 +204,83 @@ module Hecks
311
204
  key: path.map { |field, value| "#{field} = #{value}" }.join(", "))
312
205
  end
313
206
 
314
- # `count`/`median`'s own declared target — the same single
315
- # many-side head `group_by_target` resolves, for the same reason
316
- # (`seal_aggregation` already refuses zero or several many-side
317
- # heads, and refuses count/median declared alongside group_by, so
318
- # there is exactly one to name whenever this is asked at all).
319
- # Raises loudly on a `median` field that doesn't exist, or exists
320
- # but isn't numeric, rather than silently comparing garbage or
321
- # averaging strings.
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.
322
209
  def aggregation_target(model, bluebook)
323
- return nil unless model.count? || model.median_field
210
+ return nil unless model.reducing?
324
211
 
325
212
  target = model.aggregate_heads.find { |head| head[:many] }
326
- return target unless model.median_field
213
+ return target if model.count?
327
214
 
215
+ ivar, word = REDUCTION_WORD.find { |name, _| model.public_send(name) }
216
+ field = model.public_send(ivar)
328
217
  aggregate = bluebook.aggregate(target[:aggregate])
329
- attribute = aggregate.attribute(model.median_field)
218
+ attribute = aggregate.attribute(field)
330
219
  unless attribute
331
220
  raise ArgumentError,
332
- "#{model.name}'s median names #{model.median_field.inspect}, but #{target[:aggregate]} " \
221
+ "#{model.name}'s #{word} names #{field.inspect}, but #{target[:aggregate]} " \
333
222
  "declares no such attribute (it declares #{aggregate.attributes.map(&:name).join(', ')})"
334
223
  end
335
- unless QuerySpecification::FieldPath.numeric?(attribute, []) { |type| aggregate.value_object(type) }
336
- raise ArgumentError,
337
- "#{model.name}'s median names #{model.median_field.inspect} on #{target[:aggregate]}, " \
338
- "which is not numeric — median needs a numeric field (a bare number, or a " \
339
- "value object carrying one)"
340
- end
224
+ validate_reduction_type!(model, word, field, target[:aggregate], aggregate, attribute, ivar)
341
225
  target
342
226
  end
343
227
 
344
- # **The standard definition**. An odd count's median is its one true
345
- # middle value, sorted ; an even count's median is the average of
346
- # its two middle values — the common convention (as opposed to,
347
- # say, always taking the lower of the two), and the one this
348
- # session's own task named explicitly as the deliberate choice.
349
- # An empty collection has no median: nil, not zero, so a caller
350
- # cannot mistake "nothing to average" for "the values averaged to
351
- # zero". Reuses `Ports::Query::InMemory`'s own field reading
352
- # (`FieldPath.dig` + `comparable`) — the same unwrap `where`/
353
- # `order_by` already give a value-object-carrying field, so
354
- # `median :daily_limit` on a `DailyLimit{cents}` field reads the
355
- # same scalar an `order_by :daily_limit` comparison already would.
356
- def median(rows, field)
357
- values = rows.map { |record| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row(record), field)) }
358
- .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)
359
277
  return nil if values.empty?
360
278
 
361
- middle = values.length / 2
362
- 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]))
363
284
  end
364
285
 
365
286
  def fetch(bluebook, domain, aggregate_name, id)
@@ -373,20 +294,8 @@ module Hecks
373
294
  aggregate ? @registry.read_repository(domain, aggregate).all : []
374
295
  end
375
296
 
376
- # A stored reference holds the target's id inside the reference
377
- # attribute's own declared shape, so reading it is reading that shape —
378
- # a different thing from the identity unwrap that was removed. An
379
- # identity is declared as a path and followed (Runtime::Identity) ; a
380
- # reference has no path of its own, and `Value.scalar` refuses a
381
- # composite rather than guessing which field was meant.
382
- #
383
- # Storing the scalar itself would remove this reading altogether. That
384
- # is a change to how references are stored, not to how identities are
385
- # declared, so it is not made here.
386
- # An ask names itself where a command would name itself, and says the
387
- # same thing about the same shape. `Value.refuse_object_reference` is
388
- # not reused because it speaks of a command and its attribute ; a read
389
- # 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.
390
299
  def refuse_object_reference(model, args)
391
300
  offered = args.fetch(model.reference_name, nil)
392
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