hecks 1.3.0 → 1.5.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 (373) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +72 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +83 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
  7. data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
  9. data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
  10. data/lib/hecks/adapters/driven/heki.rb +106 -9
  11. data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
  12. data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
  13. data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
  15. data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
  16. data/lib/hecks/adapters/driven/lambda.rb +82 -35
  17. data/lib/hecks/adapters/driven/local_storage.rb +83 -10
  18. data/lib/hecks/adapters/driven/memory.rb +205 -9
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  24. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  25. data/lib/hecks/adapters/driven/prism.rb +50 -11
  26. data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  31. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  32. data/lib/hecks/adapters/driven/system_clock.rb +3 -0
  33. data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
  34. data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
  35. data/lib/hecks/adapters/driven.rb +6 -4
  36. data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
  37. data/lib/hecks/behaviors/dsl.rb +60 -2
  38. data/lib/hecks/behaviors/expectations.rb +190 -29
  39. data/lib/hecks/behaviors/ir.rb +12 -1
  40. data/lib/hecks/behaviors/rspec.rb +9 -1
  41. data/lib/hecks/behaviors/runner.rb +21 -2
  42. data/lib/hecks/behaviors.rb +9 -1
  43. data/lib/hecks/bluebook/aggregate.rb +43 -13
  44. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
  45. data/lib/hecks/bluebook/assembly/build.rb +26 -5
  46. data/lib/hecks/bluebook/assembly/contract.rb +98 -23
  47. data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
  48. data/lib/hecks/bluebook/assembly/marks.rb +159 -30
  49. data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
  50. data/lib/hecks/bluebook/assembly.rb +32 -14
  51. data/lib/hecks/bluebook/attribute.rb +26 -12
  52. data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
  53. data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
  54. data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
  55. data/lib/hecks/bluebook/behaviour/command.rb +55 -25
  56. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
  57. data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
  58. data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
  59. data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
  60. data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
  61. data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
  62. data/lib/hecks/bluebook/behaviour/query.rb +6 -1
  63. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
  64. data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
  65. data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
  66. data/lib/hecks/bluebook/capabilities.rb +27 -0
  67. data/lib/hecks/bluebook/chapter.rb +51 -9
  68. data/lib/hecks/bluebook/command.rb +62 -17
  69. data/lib/hecks/bluebook/domain_port.rb +34 -9
  70. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  71. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  72. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
  73. data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
  74. data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
  75. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
  76. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
  77. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  78. data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
  79. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  80. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  81. data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
  82. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  83. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
  84. data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
  85. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  86. data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
  87. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  88. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  89. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
  90. data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
  91. data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
  92. data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
  93. data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
  94. data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
  95. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  96. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  97. data/lib/hecks/bluebook/entity.rb +40 -11
  98. data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
  99. data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
  100. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
  101. data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
  102. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
  103. data/lib/hecks/bluebook/expression/resolver.rb +369 -128
  104. data/lib/hecks/bluebook/hexagon.rb +35 -1
  105. data/lib/hecks/bluebook/lifecycle.rb +12 -1
  106. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
  107. data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
  108. data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
  109. data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
  110. data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
  111. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
  112. data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
  113. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
  114. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
  115. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
  116. data/lib/hecks/bluebook/meta_validator.rb +235 -139
  117. data/lib/hecks/bluebook/model_check.rb +434 -104
  118. data/lib/hecks/bluebook/pattern_subset.rb +32 -10
  119. data/lib/hecks/bluebook/policy.rb +15 -13
  120. data/lib/hecks/bluebook/process_manager.rb +27 -14
  121. data/lib/hecks/bluebook/project_discovery.rb +5 -0
  122. data/lib/hecks/bluebook/project_loader.rb +40 -0
  123. data/lib/hecks/bluebook/project_register.rb +50 -6
  124. data/lib/hecks/bluebook/query.rb +31 -4
  125. data/lib/hecks/bluebook/read_model.rb +35 -15
  126. data/lib/hecks/bluebook/reference.rb +26 -13
  127. data/lib/hecks/bluebook/smoke_test.rb +46 -23
  128. data/lib/hecks/bluebook/synthesizer.rb +46 -12
  129. data/lib/hecks/bluebook/translation.rb +34 -5
  130. data/lib/hecks/bluebook/value_object.rb +29 -11
  131. data/lib/hecks/bluebook.rb +5 -6
  132. data/lib/hecks/codemod.rb +138 -50
  133. data/lib/hecks/construct.rb +21 -7
  134. data/lib/hecks/corpus.rb +438 -0
  135. data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
  136. data/lib/hecks/doc/reference.rb +200 -31
  137. data/lib/hecks/embryonaut_bluebook.rb +38 -15
  138. data/lib/hecks/facade/cli_door.rb +69 -10
  139. data/lib/hecks/facade/cli_runner.rb +105 -24
  140. data/lib/hecks/facade/command_request.rb +23 -0
  141. data/lib/hecks/facade/handle.rb +155 -35
  142. data/lib/hecks/facade/json_door.rb +106 -25
  143. data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
  144. data/lib/hecks/facade/surface/chapter.rb +26 -17
  145. data/lib/hecks/facade/surface.rb +16 -3
  146. data/lib/hecks/facade.rb +15 -4
  147. data/lib/hecks/forms/app.rb +46 -30
  148. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  149. data/lib/hecks/forms/field_renderer.rb +142 -6
  150. data/lib/hecks/forms/field_shape.rb +183 -19
  151. data/lib/hecks/forms/html.rb +51 -7
  152. data/lib/hecks/forms/index_renderer.rb +14 -2
  153. data/lib/hecks/forms/page.rb +14 -0
  154. data/lib/hecks/forms/params.rb +120 -23
  155. data/lib/hecks/forms/port_argument.rb +14 -2
  156. data/lib/hecks/forms/query_form_renderer.rb +65 -2
  157. data/lib/hecks/forms/record_renderer.rb +60 -2
  158. data/lib/hecks/forms/record_table.rb +28 -1
  159. data/lib/hecks/forms/reference_options.rb +24 -0
  160. data/lib/hecks/forms/value_object_shape.rb +13 -3
  161. data/lib/hecks/forms.rb +24 -4
  162. data/lib/hecks/fqn.rb +59 -1
  163. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  164. data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
  165. data/lib/hecks/framework/oidc.json +15 -0
  166. data/lib/hecks/framework.rb +79 -25
  167. data/lib/hecks/freezer.rb +27 -11
  168. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
  169. data/lib/hecks/fuzzing/combination_miner.rb +178 -0
  170. data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
  171. data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
  172. data/lib/hecks/fuzzing/differential.rb +192 -0
  173. data/lib/hecks/fuzzing/domain_generator.rb +871 -0
  174. data/lib/hecks/fuzzing/era_boundary.rb +68 -19
  175. data/lib/hecks/fuzzing/form_census.rb +121 -20
  176. data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
  177. data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
  178. data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
  179. data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
  180. data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
  181. data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
  182. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
  183. data/lib/hecks/fuzzing/properties/guards.rb +86 -42
  184. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
  185. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
  186. data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
  187. data/lib/hecks/fuzzing/properties/querying.rb +82 -28
  188. data/lib/hecks/fuzzing/properties.rb +84 -51
  189. data/lib/hecks/fuzzing/qa_settings.rb +164 -0
  190. data/lib/hecks/fuzzing/replay.rb +241 -133
  191. data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
  192. data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
  193. data/lib/hecks/fuzzing/self_consistency.rb +315 -137
  194. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
  195. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  196. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  197. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  198. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
  199. data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
  200. data/lib/hecks/fuzzing/shrinker.rb +309 -0
  201. data/lib/hecks/fuzzing/structural_skips.rb +37 -130
  202. data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
  203. data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
  204. data/lib/hecks/fuzzing/value_generator.rb +110 -22
  205. data/lib/hecks/fuzzing.rb +5 -0
  206. data/lib/hecks/grammar/evolve.rb +188 -12
  207. data/lib/hecks/grammar.rb +53 -7
  208. data/lib/hecks/ir.rb +51 -20
  209. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  210. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  211. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  212. data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
  213. data/lib/hecks/language/oidc.json +5 -0
  214. data/lib/hecks/literal.rb +41 -9
  215. data/lib/hecks/naming.rb +112 -31
  216. data/lib/hecks/ports/access_control.rb +53 -2
  217. data/lib/hecks/ports/agent/answers.rb +83 -6
  218. data/lib/hecks/ports/agent.rb +119 -35
  219. data/lib/hecks/ports/authentication.rb +44 -4
  220. data/lib/hecks/ports/authorization.rb +50 -11
  221. data/lib/hecks/ports/clock.rb +42 -23
  222. data/lib/hecks/ports/extraction.rb +16 -0
  223. data/lib/hecks/ports/identity_assignment.rb +23 -2
  224. data/lib/hecks/ports/identity_generation.rb +17 -3
  225. data/lib/hecks/ports/identity_resolution.rb +17 -1
  226. data/lib/hecks/ports/key_vault.port +6 -0
  227. data/lib/hecks/ports/key_vault.rb +58 -0
  228. data/lib/hecks/ports/loading.rb +4 -0
  229. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  230. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  231. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  232. data/lib/hecks/ports/persistence/execution.rb +4 -0
  233. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  234. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  235. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  236. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  237. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  238. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  239. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  250. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  251. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  252. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
  253. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  254. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  255. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  256. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  257. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  258. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
  259. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  260. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  261. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  262. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
  263. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
  264. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
  265. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
  266. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  267. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  268. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  269. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  270. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  271. data/lib/hecks/ports/persistence.rb +36 -1
  272. data/lib/hecks/ports/projection.rb +61 -7
  273. data/lib/hecks/ports/query/in_memory.rb +41 -8
  274. data/lib/hecks/ports/query/ordering.rb +21 -6
  275. data/lib/hecks/ports/query.rb +35 -0
  276. data/lib/hecks/ports.rb +1 -0
  277. data/lib/hecks/projections/bootstrap_table.rb +147 -0
  278. data/lib/hecks/projections/diagrams.rb +315 -79
  279. data/lib/hecks/projections/glossary/html.rb +91 -3
  280. data/lib/hecks/projections/glossary/markdown.rb +50 -3
  281. data/lib/hecks/projections/glossary/mermaid.rb +49 -1
  282. data/lib/hecks/projections/glossary/sections.rb +1 -1
  283. data/lib/hecks/projections/glossary/sentences.rb +110 -5
  284. data/lib/hecks/projections/glossary.rb +173 -31
  285. data/lib/hecks/projections/ir.rb +1 -1
  286. data/lib/hecks/projections/model/deviations.rb +62 -17
  287. data/lib/hecks/projections/model.rb +75 -21
  288. data/lib/hecks/projections/oidc.rb +45 -8
  289. data/lib/hecks/projections/parser_table.rb +53 -12
  290. data/lib/hecks/projections/reference.rb +15 -3
  291. data/lib/hecks/projections/rust_vocabulary.rb +646 -0
  292. data/lib/hecks/projections/shape.rb +10 -2
  293. data/lib/hecks/projections/statements.rb +69 -22
  294. data/lib/hecks/projections/vocabulary.rb +26 -9
  295. data/lib/hecks/projections.rb +5 -3
  296. data/lib/hecks/projector/cli_projector.rb +245 -37
  297. data/lib/hecks/projector/docs_projector.rb +154 -28
  298. data/lib/hecks/projector/exporter.rb +104 -29
  299. data/lib/hecks/projector/ir_projector.rb +7 -1
  300. data/lib/hecks/projector/narrate_projector.rb +144 -23
  301. data/lib/hecks/projector/target.rb +42 -18
  302. data/lib/hecks/projector.rb +86 -18
  303. data/lib/hecks/query_ir.rb +94 -47
  304. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  305. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  306. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  307. data/lib/hecks/query_specification/common/null_policy.rb +61 -15
  308. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  309. data/lib/hecks/query_specification/common/options.rb +25 -0
  310. data/lib/hecks/query_specification/field_path.rb +69 -15
  311. data/lib/hecks/query_specification/hop_path.rb +57 -20
  312. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  313. data/lib/hecks/rendering.rb +9 -3
  314. data/lib/hecks/router/namespace_installer.rb +16 -3
  315. data/lib/hecks/router.rb +56 -1
  316. data/lib/hecks/runtime/aggregate_lock.rb +20 -11
  317. data/lib/hecks/runtime/boot_gates.rb +21 -3
  318. data/lib/hecks/runtime/caller.rb +40 -8
  319. data/lib/hecks/runtime/capability_graph.rb +13 -2
  320. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
  321. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
  322. data/lib/hecks/runtime/command_interpreter.rb +195 -113
  323. data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
  324. data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
  325. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  326. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  327. data/lib/hecks/runtime/command_rules/references.rb +145 -55
  328. data/lib/hecks/runtime/command_rules.rb +3 -0
  329. data/lib/hecks/runtime/dependency_planning.rb +56 -11
  330. data/lib/hecks/runtime/dispatcher.rb +263 -92
  331. data/lib/hecks/runtime/entity_element.rb +304 -67
  332. data/lib/hecks/runtime/entity_interpreter.rb +149 -88
  333. data/lib/hecks/runtime/errors.rb +37 -23
  334. data/lib/hecks/runtime/event.rb +15 -10
  335. data/lib/hecks/runtime/identity.rb +91 -23
  336. data/lib/hecks/runtime/instance.rb +103 -18
  337. data/lib/hecks/runtime/interpreting.rb +24 -16
  338. data/lib/hecks/runtime/invocation.rb +358 -0
  339. data/lib/hecks/runtime/loader.rb +105 -19
  340. data/lib/hecks/runtime/outbox.rb +164 -26
  341. data/lib/hecks/runtime/policy_interpreter.rb +73 -60
  342. data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
  343. data/lib/hecks/runtime/query_interpreter.rb +93 -74
  344. data/lib/hecks/runtime/reaction_invocation.rb +73 -28
  345. data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
  346. data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
  347. data/lib/hecks/runtime/reference_hop.rb +48 -6
  348. data/lib/hecks/runtime/refusal_wording.rb +142 -115
  349. data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
  350. data/lib/hecks/runtime/registry/verification.rb +153 -28
  351. data/lib/hecks/runtime/registry.rb +202 -32
  352. data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
  353. data/lib/hecks/runtime/routing.rb +36 -154
  354. data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
  355. data/lib/hecks/runtime/saga_interpreter.rb +90 -76
  356. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  357. data/lib/hecks/runtime/tenant_check.rb +33 -13
  358. data/lib/hecks/runtime/tenant_scope.rb +23 -5
  359. data/lib/hecks/runtime/value/admission.rb +75 -30
  360. data/lib/hecks/runtime/value/coercion.rb +555 -142
  361. data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
  362. data/lib/hecks/runtime/value.rb +71 -21
  363. data/lib/hecks/runtime.rb +39 -7
  364. data/lib/hecks/storehouse.rb +368 -72
  365. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
  366. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
  367. data/lib/hecks/version.rb +3 -3
  368. data/lib/hecks/vocabulary.rb +205 -4
  369. data/lib/hecks.rb +91 -11
  370. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
  371. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
  372. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
  373. metadata +28 -2
@@ -12,7 +12,7 @@ module Hecks
12
12
  # typed Value. Extended into Value, so every method here reads as
13
13
  # `Value.for`, `Value.build`, … — `self` is the Value class.
14
14
  module Coercion
15
- # THE FOUR SHAPES AN ATTRIBUTE'S VALUE CAN TAKE — named here because
15
+ # The four `SHAPES` an attribute's value can take — named here because
16
16
  # `for_attribute` immediately below is the one place that actually
17
17
  # branches on all four, and nowhere else in the language collects
18
18
  # them into a single closed list. `Attribute#list?`/`#optional?`
@@ -23,7 +23,7 @@ module Hecks
23
23
  # below — but the branch is exactly as real, so it gets a name here
24
24
  # too rather than staying anonymous.
25
25
  #
26
- # A SECOND RUNTIME'S KERNEL PORTS THIS METHOD BY HAND (rust/src/
26
+ # A second runtime's kernel ports this method by hand (rust/src/
27
27
  # kernel/attribute_shapes/*.rs — one file per name in this array,
28
28
  # generated into a Rust enum by bin/project_kernel_capabilities so
29
29
  # every match over it is compiler-checked exhaustive). If a fifth
@@ -34,6 +34,20 @@ module Hecks
34
34
  # about no matter how correct the Ruby below is.
35
35
  SHAPES = %i[scalar list optional composite].freeze
36
36
 
37
+ # Coerces `value` for one of `aggregate`'s declared attributes, by name.
38
+ #
39
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity] the construct
40
+ # `name` is looked up on
41
+ # @param name [String, Symbol] the declared attribute name
42
+ # @param value [Object] the raw value to coerce
43
+ # @return [Runtime::Value, Object, nil] `value` unchanged when `aggregate`
44
+ # declares no such attribute; otherwise `for_attribute`'s own result
45
+ # @raise [Runtime::TypeMismatch] if `value` cannot be coerced to the
46
+ # attribute's declared type
47
+ # @raise [Runtime::UnknownArgument] if `value` is a Hash naming a field the
48
+ # attribute's own value-object type does not declare
49
+ # @raise [Runtime::InvariantViolation] if a coerced value object breaks one
50
+ # of its own invariants
37
51
  def for(aggregate, name, value)
38
52
  attribute = aggregate.attribute(name)
39
53
  return value unless attribute
@@ -49,13 +63,13 @@ module Hecks
49
63
  # recursively via `build`) with `scalar` as what's left once
50
64
  # neither of those applies (the raw value, passed through
51
65
  # unchanged).
52
- # `boundary: false` is the QUERY door (`QueryInterpreter#normalize_args`):
66
+ # `boundary: false` is the query door (`QueryInterpreter#normalize_args`):
53
67
  # a query attribute's declared type names the argument for callers and
54
68
  # generators, never a runtime shape — comparison unwraps both sides
55
69
  # itself, so a `reference: {value: ...}` offered against a `String`
56
70
  # query field is the documented allowance (see banking's own
57
71
  # `Account.OpenForCustomer`), not a C3.8 mismatch.
58
- # `argument: true` is the COMMAND/ENTITY/PORT argument door only
72
+ # `argument: true` is the command/entity/port argument door only
59
73
  # (`Interpreting#normalize_args`, every command/entity/port
60
74
  # dispatch): the one place a nil for a non-optional attribute is
61
75
  # the caller leaving a required argument empty (C3.7), absorbed
@@ -66,11 +80,35 @@ module Hecks
66
80
  # legitimate "absent is not empty" value the aggregate's own
67
81
  # attribute may hold. `QueryInterpreter#normalize_args` never
68
82
  # passes `argument: true` — a null required value-object-typed
69
- # QUERY argument is checked, and refused, entirely on its own
83
+ # query argument is checked, and refused, entirely on its own
70
84
  # side (`null_vo_argument!`, query_interpreter.rb) precisely so
71
- # it does NOT reach this default-absorbing fallback (QualityControl
85
+ # it does not reach this default-absorbing fallback (QualityControl
72
86
  # BUG#36 — a query's own null VO argument must refuse regardless
73
87
  # of any default, unlike a command's).
88
+ #
89
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity] the construct
90
+ # `attribute` is declared on
91
+ # @param attribute [Bluebook::Attribute, nil] the attribute to coerce `value`
92
+ # for; nil is treated as an unknown attribute (no shape to coerce against)
93
+ # @param value [Object] the raw value to coerce
94
+ # @param boundary [Boolean] whether a bare-primitive attribute's own scalar
95
+ # shape is checked; false at the query door, where a declared type names
96
+ # the argument for callers/generators only
97
+ # @param argument [Boolean] whether this is the command/entity/port argument
98
+ # door, where a nil for a non-optional attribute is a left-empty argument
99
+ # (C3.7) rather than ordinary state-assembly nil
100
+ # @return [Runtime::Value, Object, nil] the coerced value: a built `Value`
101
+ # for a composite-typed attribute, a frozen Array for a `has_many`
102
+ # reference or hydrated entity list, a joined identity String for a
103
+ # scalar reference, or `value` passed through unchanged for a bare
104
+ # primitive or an unknown attribute; nil for an absent optional attribute
105
+ # @raise [Runtime::TypeMismatch] if `value` cannot be coerced to the
106
+ # attribute's declared type, or a required reference/attribute is offered
107
+ # as a wrong-shaped value
108
+ # @raise [Runtime::UnknownArgument] if `value` is a Hash naming a field the
109
+ # attribute's own value-object type does not declare
110
+ # @raise [Runtime::InvariantViolation] if a coerced value object breaks one
111
+ # of its own invariants
74
112
  def for_attribute(aggregate, attribute, value, boundary: true, argument: false)
75
113
  return nil_or_missing(aggregate, attribute, value, argument) if attribute.nil? || value.nil?
76
114
  return reference_list(attribute, value) if attribute.list? && attribute.reference?
@@ -78,12 +116,12 @@ module Hecks
78
116
  return hydrate_entity_list(aggregate, attribute, value) if attribute.list? # :list
79
117
  return value unless aggregate.respond_to?(:value_object)
80
118
 
81
- # THE SET THE ATTRIBUTE NAMES IS CHECKED WHERE THE ATTRIBUTE IS KNOWN.
119
+ # The set the attribute names is checked where the attribute is known.
82
120
  # `build` below sees only the value object, never which attribute asked
83
121
  # for it, so a command argument's `admits:` has to be read here — this
84
122
  # is the door every argument and every head field comes through.
85
123
  #
86
- # AFTER coercion, not before: a scalar arrives wrapped in whatever holder
124
+ # After coercion, not before: a scalar arrives wrapped in whatever holder
87
125
  # its type names (`{value: "append"}` for an OpName), and checking the
88
126
  # raw payload would be checking the envelope.
89
127
  value_object = value_object_for(aggregate, attribute.type)
@@ -99,12 +137,12 @@ module Hecks
99
137
  coerced
100
138
  end
101
139
 
102
- # NIL IS NOT A VALUE FOR A NON-OPTIONAL ARGUMENT (C3.7/C3.8). An
140
+ # NIL is not a value for a non-optional argument (C3.7/C3.8). An
103
141
  # `optional:` attribute and a load from the store pass nil through
104
142
  # as they always did; a command argument offered as null for a
105
143
  # required attribute is
106
144
  # refused as the field the caller left empty — a value object is
107
- # BUILT from no fields, so its first required field refuses with
145
+ # built from no fields, so its first required field refuses with
108
146
  # exactly the wording the Rust side's `from_json` gives it
109
147
  # ("Money.cents expects Integer, got nil"), and a bare scalar
110
148
  # refuses through `check_bare_primitive`'s own wording. Lists and
@@ -112,7 +150,7 @@ module Hecks
112
150
  # relationship, `validate_relationship_cardinality`'s business).
113
151
  # The `attribute.nil?`/`value.nil?` branch of `for_attribute`,
114
152
  # pulled out on its own — an unknown attribute has no shape left
115
- # to branch on, and a nil VALUE is either an ordinary absence
153
+ # to branch on, and a nil value is either an ordinary absence
116
154
  # (state assembly, hydration, a query ask) or, at the argument
117
155
  # door only, `nil_argument`'s own C3.7 refusal.
118
156
  private def nil_or_missing(aggregate, attribute, value, argument)
@@ -129,12 +167,12 @@ module Hecks
129
167
  return build(value_object, {}, aggregate) if value_object
130
168
 
131
169
  raise TypeMismatch,
132
- RefusalWording.render("TypeMismatch", "numeric_field",
133
- type: aggregate.hecks_name, field: attribute.name,
134
- expected: attribute.type, offered: "nil")
170
+ RefusalWording.render_site("TypeMismatch", "numeric_field",
171
+ type: aggregate.hecks_name, field: attribute.name,
172
+ expected: attribute.type, offered: "nil")
135
173
  end
136
174
 
137
- # A BARE PRIMITIVE IS TYPE-CHECKED AT THE BOUNDARY TOO (C3.8,
175
+ # A bare primitive is type-checked at the boundary too (C3.8,
138
176
  # docs/semantics/bluebook-semantics.md) — the same two predicates a
139
177
  # value object's own fields get, so wrong-typed caller input is a
140
178
  # TypeMismatch refusal here, never an evaluation fault later. Its
@@ -145,10 +183,20 @@ module Hecks
145
183
  value
146
184
  end
147
185
 
186
+ # Resolves the value-object class `type` names, searching `aggregate`'s own
187
+ # declarations first, then its chapter's other aggregates.
188
+ #
148
189
  # Aggregate-local value objects remain authoritative, which permits
149
190
  # intentional duplication. An ordinary fact may also name an identity
150
191
  # value object declared on another aggregate; that shape is borrowed
151
192
  # only when every chapter declaration with the name agrees.
193
+ #
194
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity] the construct to
195
+ # search first
196
+ # @param type [String, Symbol, #to_s] the declared type name to resolve
197
+ # @return [Class, nil] the `Bluebook::ValueObject` subclass `type` names; nil
198
+ # if `aggregate` declares none, and its chapter's other aggregates disagree
199
+ # on the shape of every same-named one (or declare none either)
152
200
  def value_object_for(aggregate, type)
153
201
  local = aggregate.value_object(type)
154
202
  return local if local
@@ -163,11 +211,21 @@ module Hecks
163
211
  shapes.size == 1 ? matches.first : nil
164
212
  end
165
213
 
214
+ # Coerces a reference-typed attribute's offered value into the target's own
215
+ # canonical identity string.
216
+ #
166
217
  # Retained relationships store canonical target identities, not Ruby
167
218
  # Value wrappers. Raw scalar IDs remain a compatibility input. A named
168
219
  # identity VO omits its minted aggregate field at the command boundary;
169
220
  # a bespoke compound VO may instead name the target heads directly.
170
221
  # Neither form requires reverse-splitting a canonical ID.
222
+ #
223
+ # @param attribute [Bluebook::Attribute] the reference-typed attribute
224
+ # @param value [Object] the offered value: a bare scalar identity, a
225
+ # `Runtime::Value`, or a Hash naming the target's own identity fields
226
+ # @return [String, Object] the joined canonical identity String once every
227
+ # identity part resolves; `value` unchanged otherwise (a bare scalar, an
228
+ # unresolvable target, or a shape `sole_scalar_identity` cannot unwrap)
171
229
  def reference_identity(attribute, value)
172
230
  return value unless value.is_a?(self) || value.is_a?(Hash)
173
231
 
@@ -180,14 +238,64 @@ module Hecks
180
238
 
181
239
  direct_head = direct_identity_head(value, target)
182
240
  parts = paths.map { |path| identity_part(materialized, path, direct_head) }
183
- return value if parts.any? { |part| part.nil? || (part.respond_to?(:empty?) && part.empty?) }
241
+ if parts.any? { |part| part.nil? || (part.respond_to?(:empty?) && part.empty?) }
242
+ return sole_scalar_identity(value, paths) || value
243
+ end
184
244
 
185
245
  Naming.identity(parts)
186
246
  end
187
247
 
248
+ # A command may redeclare a `reference_to` field under its own,
249
+ # differently-named single-attribute value object (`attribute
250
+ # :venue, VenueHandle; sets :venue`, not `reference_to Venue` —
251
+ # QualityControl BUG#121, `qa/stress_domains/generated_revalued_
252
+ # shape`'s own `Hangar.Repoint`) instead of naming the target's
253
+ # own identity field(s) directly. `identity_part`'s path walk
254
+ # above only ever matches an incoming shape that already uses the
255
+ # target's own field names (or is the target's own identity value
256
+ # object, `direct_identity_head`) — an ad hoc wrapper around a
257
+ # bare scalar under some other field name (`VenueHandle`'s own
258
+ # `value`, not `Venue`'s own `code`) fails every path lookup, and
259
+ # without this would fall through to the unresolved `return value`
260
+ # above, storing the wrapped Value. That leaves one reference field on
261
+ # one aggregate holding two different shapes depending on which
262
+ # command last wrote it — `Open`'s own bare `reference_to Venue`
263
+ # argument was never wrapped in the first place (the top guard
264
+ # clause passes a bare scalar straight through), so it always
265
+ # stored the canonical bare identity this class's own header
266
+ # comment promises ("canonical target identities, not Ruby Value
267
+ # wrappers") — while `Repoint` silently kept the wrapper instead.
268
+ #
269
+ # Unambiguous only when both sides admit exactly one scalar: the
270
+ # target names exactly one identity path (`paths.one?` — a
271
+ # compound identity has no single field either side could stand
272
+ # in for) and the offered value is itself a single-attribute
273
+ # value object (`sole_attribute` — a multi-field VO has no one
274
+ # scalar to unwrap either). `materialize_unwrapped` recurses
275
+ # through any further single-field wrapping the same way it
276
+ # already does for `Value.materialize_unwrapped`'s other callers,
277
+ # landing on the bare scalar `Open`'s own path already produces
278
+ # for the identical target field.
279
+ #
280
+ # @param value [Object] the offered reference value to unwrap
281
+ # @param paths [Array<String>] the target's own declared identity paths
282
+ # @return [Object, nil] the unwrapped scalar when `value` is a single-field
283
+ # value object and `paths` names exactly one path; nil otherwise
284
+ def sole_scalar_identity(value, paths)
285
+ return nil unless value.is_a?(self) && paths.one?
286
+ return nil unless value.value_object.sole_attribute
287
+
288
+ materialize_unwrapped(value)
289
+ end
290
+
188
291
  # Whether `value` is itself the target's own (single) identity
189
292
  # value object — pure, self-contained: reads only `value` and
190
293
  # `target`, decides nothing about any particular path.
294
+ #
295
+ # @param value [Object] the offered reference value to check
296
+ # @param target [Bluebook::Aggregate] the reference's own resolved target
297
+ # @return [String, nil] the target's own single identity head, as a String,
298
+ # when `value` is that head's own declared value-object type; nil otherwise
191
299
  def direct_identity_head(value, target)
192
300
  return nil unless value.is_a?(self) && target.identity_heads.one?
193
301
 
@@ -201,6 +309,14 @@ module Hecks
201
309
  # three inputs; extracted from `reference_identity` alongside
202
310
  # `direct_identity_head` above purely to keep that method to its
203
311
  # own guard-clause shape.
312
+ #
313
+ # @param materialized [Hash, Object] the offered reference value, already
314
+ # materialized to a plain Hash (or scalar, for a path that finds nothing)
315
+ # @param path [String, Symbol] one dotted identity path to dig
316
+ # @param direct_head [String, nil] the leading segment to strip, when `path`
317
+ # restates the head `direct_identity_head` already matched
318
+ # @return [Object, nil] the value found by walking `path`'s segments; nil if
319
+ # any segment is missing or a non-Hash is dug into before the path ends
204
320
  def identity_part(materialized, path, direct_head)
205
321
  segments = path.to_s.split(".")
206
322
  segments.shift if direct_head && segments.first == direct_head
@@ -215,6 +331,12 @@ module Hecks
215
331
  end
216
332
  end
217
333
 
334
+ # Coerces a `has_many` reference-typed attribute's offered value.
335
+ #
336
+ # @param attribute [Bluebook::Attribute] the `has_many` reference attribute
337
+ # @param value [Object] the offered value; must be an Array
338
+ # @return [Array] `value`, deep-frozen and duped
339
+ # @raise [Runtime::TypeMismatch] if `value` is not an Array
218
340
  def reference_list(attribute, value)
219
341
  unless value.is_a?(Array)
220
342
  raise TypeMismatch,
@@ -224,6 +346,18 @@ module Hecks
224
346
  Freezer.deep(value.dup)
225
347
  end
226
348
 
349
+ # Normalizes an offered value into `value_object`'s own field Hash, before
350
+ # defaults, nested normalization and validation run.
351
+ #
352
+ # @param value_object [Class] the target `Bluebook::ValueObject` subclass
353
+ # @param name [String, Symbol] the attribute or argument name, quoted in a
354
+ # refusal
355
+ # @param value [Hash, Runtime::Value, Object] the offered value: a Hash of
356
+ # fields, an already-built `Value` of a differently-named but same-shaped
357
+ # type, or a bare scalar for a single-field value object
358
+ # @return [Hash{Symbol => Object}] the offered fields, keyed by attribute name
359
+ # @raise [Runtime::TypeMismatch] if `value` is a bare scalar and `value_object`
360
+ # declares more than one field
227
361
  def fields_for(value_object, name, value)
228
362
  return value.transform_keys(&:to_sym) if value.is_a?(Hash)
229
363
  # Mutations may legitimately carry a value object into a differently
@@ -235,14 +369,14 @@ module Hecks
235
369
 
236
370
  # Vendored addition, not (yet) upstream hecks (migration
237
371
  # plan task 5): a bare scalar auto-wraps into a single-field
238
- # value object's sole attribute -- the SAME shape
372
+ # value object's sole attribute -- the same shape
239
373
  # #from_identifier already establishes for identity coercion
240
374
  # (`build(value_object, { fields.first.name => identifier }) if
241
- # fields.size == 1`), made consistent here for MUTATION
375
+ # fields.size == 1`), made consistent here for mutation
242
376
  # coercion too. Real, corpus-wide gap: a synthesised single-
243
377
  # field wrapper (Part 3a's bare-primitive auto-synthesis, the
244
378
  # norm for a VO-typed aggregate field) is exactly the shape
245
- # #rewrap_arithmetic_result hands back a raw scalar RESULT to
379
+ # #rewrap_arithmetic_result hands back a raw scalar result to
246
380
  # -- without this, every phantom-field increment/multiply on a
247
381
  # single-field-wrapped attribute refused with "pass its fields
248
382
  # as an object, not <scalar>" the instant it tried to re-wrap
@@ -253,40 +387,40 @@ module Hecks
253
387
  return { value_object.attributes.first.name => value } if value_object.attributes.size == 1
254
388
 
255
389
  raise TypeMismatch,
256
- RefusalWording.render("TypeMismatch", "value_object_shape",
257
- name: name, type: value_object.hecks_name,
258
- offered: Rendering.describe(value))
390
+ RefusalWording.render_site("TypeMismatch", "value_object_shape",
391
+ name: name, type: value_object.hecks_name,
392
+ offered: Rendering.describe(value))
259
393
  end
260
394
 
261
395
  # `build`'s own recursive twin of `for_attribute`'s single-level
262
- # normalization — a value object's OWN composite-typed fields
396
+ # normalization — a value object's own composite-typed fields
263
397
  # (`Pizza.price_cents`, a `Price`) never otherwise pass back
264
398
  # through `fields_for`, so a bare scalar or partial Hash for one
265
- # of THOSE sails past the outer VO's own shape check (`Pizza`
399
+ # of those sails past the outer VO's own shape check (`Pizza`
266
400
  # itself has two fields, so nothing unwraps there) and lands
267
401
  # stored one field down exactly as handed in — found live: once
268
402
  # the fuzzer actually generated the bare-scalar shape
269
- # `fields_for` has accepted at the TOP level since 86727afd, a
403
+ # `fields_for` has accepted at the top level since 86727afd, a
270
404
  # nested `Price` stored as a raw Integer broke every later
271
405
  # dotted-path read (`pizza.price_cents.cents`) expecting one
272
406
  # more level of Hash.
273
407
  #
274
408
  # Stays a plain Hash, never a nested `Value` — `Value#with`'s own
275
409
  # header and `materialize_unwrapped`'s comment already depend on
276
- # a value-object-typed field of ANOTHER value object staying a
410
+ # a value-object-typed field of another value object staying a
277
411
  # plain Hash once stored, and this does not change that; it only
278
412
  # makes sure that Hash has the shape its own type declares.
279
- # `aggregate` is the one thing `build` didn't used to need — a
280
- # nested type can only be resolved through `aggregate.
413
+ # `aggregate` is the one thing this needs beyond `fields_for`'s own
414
+ # scope — a nested type can only be resolved through `aggregate.
281
415
  # value_object(name)`, so callers with no aggregate in reach
282
416
  # (`Value#with`, always re-setting an already-scalar arithmetic
283
- # field) simply skip this and keep their prior behavior.
284
- # RECURSES INTO EACH NESTED FIELD'S OWN VALIDATION TOO, not only its
417
+ # field) simply skip this and keep their own behavior unchanged.
418
+ # Recurses into each nested field's own validation too, not only its
285
419
  # shape — found live alongside the shape bug this method's header
286
420
  # already describes: a nested `Price`/`Size` (a value-object-typed
287
- # field of ANOTHER value object, e.g. `Pizza.price_cents`,
421
+ # field of another value object, e.g. `Pizza.price_cents`,
288
422
  # `Pizza.size`) had its Hash shape normalized here but never ran
289
- # `validate!` — `build`, below, only ever validated the OUTER value
423
+ # `validate!` — `build`, below, only ever validated the outer value
290
424
  # object's own direct fields, so a negative `price_cents.cents` or an
291
425
  # out-of-`one_of` `size.value` sailed through a `Pizza`-typed command
292
426
  # argument untouched, while the exact same nested type declared as a
@@ -295,11 +429,43 @@ module Hecks
295
429
  # defaults` runs first, same as the outer value object gets in
296
430
  # `build`, so a nested field's own default is filled in before its
297
431
  # own invariants read it.
432
+ #
433
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity, nil] the construct
434
+ # nested types are resolved against; a no-op if it does not respond to
435
+ # `value_object`
436
+ # @param value_object [Class] the `Bluebook::ValueObject` subclass `fields`
437
+ # belongs to
438
+ # @param fields [Hash{Symbol => Object}] the outer value object's own fields,
439
+ # already defaulted; written in place
440
+ # @return [Hash{Symbol => Object}] `fields`, with every composite-typed field
441
+ # normalized (and, for a non-list one, validated) into its own declared shape
442
+ # @raise [Runtime::TypeMismatch] if a nested field cannot be coerced to its
443
+ # declared type
444
+ # @raise [Runtime::UnknownArgument] if a nested field's own Hash names a
445
+ # field its declared type does not declare
446
+ # @raise [Runtime::InvariantViolation] if a nested field breaks one of its
447
+ # own invariants
298
448
  def normalize_composite_fields(aggregate, value_object, fields)
299
449
  return fields unless aggregate.respond_to?(:value_object)
300
450
 
301
451
  value_object.attributes.each do |attribute|
302
- next if attribute.list? || !fields.key?(attribute.name)
452
+ next unless fields.key?(attribute.name)
453
+
454
+ # A list member read back from the store hydrates like a top-level
455
+ # list — `list_of(Entity)` elements get their fields coerced,
456
+ # `list_of(ValueObject)` elements become Values — so a value object
457
+ # holding a list loads into the same shape the live dispatch that
458
+ # wrote it held. Found by PR A3 (every adapter through the state
459
+ # codec): chess-style `sets :positions, append: { pieces:
460
+ # state(:pieces) }` snapshots read back from Heki/Sqlite/Postgres
461
+ # (and now Memory's codec copy) with raw element hashes, so a
462
+ # `given` comparing a snapshot piece's `id` Value to a live one
463
+ # never matched after a restart. Load door only: an input list
464
+ # member is left exactly as before, refusals unchanged.
465
+ if attribute.list?
466
+ fields[attribute.name] = for_attribute(aggregate, attribute, fields[attribute.name]) if trusting_stored_state?
467
+ next
468
+ end
303
469
 
304
470
  raw = fields[attribute.name]
305
471
  next if raw.nil? || raw.is_a?(self)
@@ -316,26 +482,47 @@ module Hecks
316
482
  fields
317
483
  end
318
484
 
485
+ # Fills every declared attribute `fields` does not already hold with its
486
+ # own declared `default:`, when it has one.
487
+ #
488
+ # @param value_object [Class] the `Bluebook::ValueObject` subclass whose
489
+ # declared defaults are read
490
+ # @param fields [Hash{Symbol => Object}] the offered fields; written in place
491
+ # @return [Hash{Symbol => Object}] `fields`, with each declared default filled in
319
492
  def apply_defaults(value_object, fields)
320
493
  value_object.attributes.each_with_object(fields) do |attribute, completed|
321
494
  completed[attribute.name] = attribute.default unless completed.key?(attribute.name) || attribute.default.nil?
322
495
  end
323
496
  end
324
497
 
325
- # THE FULL DOOR A VALUE OBJECT'S OWN FIELDS PASS THROUGH — shared by
498
+ # The full door a value object's own fields pass through — shared by
326
499
  # `build` (the outer value object) and `normalize_composite_fields`
327
500
  # (every nested one), so a nested `Price`/`Size` is refused exactly
328
501
  # the same way, with exactly the same wording, as the identical type
329
502
  # declared directly on a command.
503
+ #
504
+ # @param value_object [Class] the `Bluebook::ValueObject` subclass to
505
+ # validate `fields` against
506
+ # @param fields [Hash{Symbol => Object}] the already-defaulted, already
507
+ # nested-normalized fields to check
508
+ # @return [void]
509
+ # @raise [Runtime::UnknownArgument] if `fields` names a key `value_object`
510
+ # does not declare
511
+ # @raise [Runtime::TypeMismatch] if a required field is missing, a numeric
512
+ # or pattern-constrained field is the wrong shape, or a scalar field
513
+ # arrives as a composite
514
+ # @raise [Runtime::InvariantViolation] if `fields` breaks one of
515
+ # `value_object`'s own declared invariants
330
516
  def validate!(value_object, fields)
331
517
  # C6.3 (docs/semantics/bluebook-semantics.md) — a value object is
332
- # validated on CONSTRUCTION FROM INPUT only; state read back from
518
+ # validated on construction from input only; state read back from
333
519
  # the store is trusted as it was written, so tightening an
334
520
  # invariant never makes an old record unreadable (migration is
335
521
  # the era system's job). `hydrate` — the one load door — sets
336
522
  # the flag; every input door leaves it unset.
337
523
  return if trusting_stored_state?
338
524
 
525
+ check_unknown_fields(value_object, fields)
339
526
  check_required_fields(value_object, fields)
340
527
  admit_member(value_object, fields)
341
528
  check_admitted(value_object, fields)
@@ -346,12 +533,28 @@ module Hecks
346
533
  next if Bluebook::Expression::Evaluator.call_rule(invariant, fields)
347
534
 
348
535
  raise InvariantViolation,
349
- RefusalWording.render("InvariantViolation", "value_object_invariant",
350
- name: value_object.hecks_name, description: invariant.description,
351
- offered: canonical_fields(fields))
536
+ RefusalWording.render_site("InvariantViolation", "value_object_invariant",
537
+ name: value_object.hecks_name, description: invariant.description,
538
+ offered: canonical_fields(fields))
352
539
  end
353
540
  end
354
541
 
542
+ # Builds one validated `Value` of `value_object`'s own type: defaults filled,
543
+ # nested fields normalized and validated, then the whole thing checked.
544
+ #
545
+ # @param value_object [Class] the `Bluebook::ValueObject` subclass to build
546
+ # @param fields [Hash{Symbol, String => Object}] the offered field values,
547
+ # either key spelling
548
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity, nil] the construct
549
+ # a nested composite field is resolved against; nil skips nested
550
+ # normalization entirely
551
+ # @return [Runtime::Value] the built, validated value object
552
+ # @raise [Runtime::UnknownArgument] if `fields` (or a nested field) names a
553
+ # key its own type does not declare
554
+ # @raise [Runtime::TypeMismatch] if a field (or a nested one) cannot be
555
+ # coerced to its declared type
556
+ # @raise [Runtime::InvariantViolation] if the built value object (or a
557
+ # nested one) breaks one of its own invariants
355
558
  def build(value_object, fields, aggregate = nil)
356
559
  fields = apply_defaults(value_object, fields.transform_keys(&:to_sym))
357
560
  fields = normalize_composite_fields(aggregate, value_object, fields)
@@ -359,10 +562,33 @@ module Hecks
359
562
  new(value_object, fields)
360
563
  end
361
564
 
565
+ # State arrives decoded or not at all (Phase 2, Track A, PR A4).
566
+ # Every persistence adapter reads through `Ports::Persistence::
567
+ # StateCodec.decode` (A3), which symbolizes every top-level key, and
568
+ # the runtime's own callers (entity elements, the remote dispatcher's
569
+ # `symbolize_names:` parse, Era's audit) build symbol-keyed state
570
+ # themselves. A String key here is an adapter or caller that skipped
571
+ # the codec, so it is refused by name rather than respelled: a silent
572
+ # `to_sym` here would hide exactly this kind of bypass instead. Always
573
+ # on, because it costs one `is_a?` per key, no more than a `to_sym` would.
574
+ #
575
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity] the construct
576
+ # whose declared attributes coerce `state`'s own values
577
+ # @param state [Hash{Symbol => Object}] the stored state to hydrate; every
578
+ # key must already be a Symbol
579
+ # @return [Hash{Symbol => Object}] `state`, coerced through every declared
580
+ # attribute it names
581
+ # @raise [Runtime::WiringError] if `state` holds any non-Symbol key
362
582
  def hydrate(aggregate, state)
583
+ undecoded = state.keys.grep_v(Symbol)
584
+ unless undecoded.empty?
585
+ raise WiringError,
586
+ "#{aggregate.name} state reached hydration with non-Symbol keys #{undecoded.inspect} — " \
587
+ "decode stored state through Hecks::Ports::Persistence::StateCodec.decode first"
588
+ end
589
+
363
590
  trusting_stored_state do
364
- state.each_with_object({}) do |(name, value), hydrated|
365
- key = name.to_sym
591
+ state.each_with_object({}) do |(key, value), hydrated|
366
592
  attribute = aggregate.attribute(key)
367
593
  hydrated[key] = attribute ? for_attribute(aggregate, attribute, value) : value
368
594
  end
@@ -371,6 +597,15 @@ module Hecks
371
597
 
372
598
  TRUSTED_LOAD_KEY = :hecks_trusting_stored_state
373
599
 
600
+ # Marks the block as loading trusted, already-validated stored state, so
601
+ # `validate!` skips its own checks for the block's duration.
602
+ #
603
+ # `Thread.current`-backed, not a plain ivar, so two threads hydrating
604
+ # concurrently on the same `Value` singleton class never see or clear
605
+ # each other's flag.
606
+ #
607
+ # @yield the code that should see `trusting_stored_state?` true
608
+ # @return [Object] the block's result
374
609
  def trusting_stored_state
375
610
  previous = Thread.current[TRUSTED_LOAD_KEY]
376
611
  Thread.current[TRUSTED_LOAD_KEY] = true
@@ -379,25 +614,89 @@ module Hecks
379
614
  Thread.current[TRUSTED_LOAD_KEY] = previous
380
615
  end
381
616
 
617
+ # Reports whether the current thread is inside a `trusting_stored_state` block.
618
+ #
619
+ # @return [Boolean] true if a `trusting_stored_state` block is on this
620
+ # thread's own call stack
382
621
  def trusting_stored_state? = Thread.current[TRUSTED_LOAD_KEY] == true
383
622
 
384
- # `Value.identifier` used to live here: hand it a one-field value object
385
- # and it opened it, so `identified_by :number` could pass for an identity
386
- # and the runtime would guess which field was meant. THAT GUESS IS GONE.
623
+ # QualityControl BUG#125 — the one narrow door `check_scalar_shapes`
624
+ # keeps open, now that a non-string scalar is otherwise refused for a
625
+ # String-typed field. `MetaValidator::Judge#send_to` — the single
626
+ # choke point every one of the language's own self-hosted dispatches
627
+ # goes through while walking a bluebook's declarations into the
628
+ # "Bluebook" meta-domain — wraps itself in this, and nothing else
629
+ # does. `Judge#appends`' generic `POSITION` handling
630
+ # (judge.rb#appends) keys purely off a field being named "position",
631
+ # the convention every other append list actually uses it for
632
+ # (ValueObject::Member, ProcessManager::Handler, ... — all really
633
+ # `Position`/Integer-typed); `Normalise`'s own `NormalisationRule`
634
+ # happens to also name its own domain field "position"
635
+ # (bluebook.bluebook), but declares it `RuleText` (String) — so the
636
+ # same walk-index substitution (`Judge#v(index)`) hands it a raw
637
+ # Integer too, on every domain's very first boot (the language
638
+ # self-judges its own grammar via `MetaValidator.fresh_runtime`'s
639
+ # fixpoint). Confirmed (QualityControl BUG#125 investigation): the
640
+ # resulting value is never read back — `normalisations` is an
641
+ # `ELSEWHERE`/`derived` field spliced straight from
642
+ # `Expression::CanonicalForm.table` (assembly/contracts.rb), so the
643
+ # judged record holding the Integer is discarded whole — this is a
644
+ # walk-index/domain-field name collision inside `Judge#appends`, not
645
+ # a genuine semantic need for `position` to arrive numeric. Fixing
646
+ # that collision at its own root is a separate, larger change to
647
+ # self-hosted bootstrap mechanics that every domain's boot depends
648
+ # on; this flag only ever loosens scalar-shape checking for the
649
+ # meta-grammar's own value objects (RuleText, BluebookName, Position,
650
+ # …) that Judge itself constructs while walking a bluebook's
651
+ # declarations — never for a real domain's own declared value
652
+ # objects (PieceId, Money, …), which Judge never dispatches commands
653
+ # against. `offer` (judge.rb) already converts a `TypeMismatch` here
654
+ # into a recorded refusal rather than letting it propagate, but
655
+ # `MetaValidator.call` raises the instant `refusals` is non-empty
656
+ # (meta_validator.rb) — so, unexempted, this would fail every
657
+ # domain's boot, not just the language's own bootstrap. Composite
658
+ # shapes (Array/Hash) stay refused unconditionally, bootstrap or not
659
+ # — nothing Judge does ever legitimately needs those for a scalar
660
+ # field.
661
+ BOOTSTRAP_KEY = :hecks_judge_bootstrapping
662
+
663
+ # Marks the block as `MetaValidator::Judge#send_to`'s own self-hosted
664
+ # bootstrap dispatch, so `check_scalar_shapes` loosens its `String` check
665
+ # for the block's duration.
666
+ #
667
+ # `Thread.current`-backed, not a plain ivar, so two threads bootstrapping
668
+ # concurrently on the same `Value` singleton class never see or clear
669
+ # each other's flag.
670
+ #
671
+ # @yield the code that should see `judge_bootstrapping?` true
672
+ # @return [Object] the block's result
673
+ def judge_bootstrapping
674
+ previous = Thread.current[BOOTSTRAP_KEY]
675
+ Thread.current[BOOTSTRAP_KEY] = true
676
+ yield
677
+ ensure
678
+ Thread.current[BOOTSTRAP_KEY] = previous
679
+ end
680
+
681
+ # Reports whether the current thread is inside a `judge_bootstrapping` block.
682
+ #
683
+ # @return [Boolean] true if a `judge_bootstrapping` block is on this
684
+ # thread's own call stack
685
+ def judge_bootstrapping? = Thread.current[BOOTSTRAP_KEY] == true
686
+
387
687
  # An identity names its field — `identified_by :number` — and the
388
- # path is what reaches the scalar. A declaration that names no field is
389
- # refused when the bluebook loads, so nothing has to be unwrapped later.
688
+ # path is what reaches the scalar, never a guess at which field a
689
+ # one-field value object might mean. A declaration that names no
690
+ # field is refused when the bluebook loads, so nothing has to be
691
+ # unwrapped later.
390
692
  #
391
693
  # `scalar` below is a different job and stays: rendering a value object
392
694
  # into a column or a message, where there is no path to consult.
393
695
 
394
- # `Value.reference_id` lived here, opening a reference to find the id
395
- # inside it. A reference IS the id now — refused at the payload gate if it
396
- # arrives as anything else — so there is nothing left to open. The comment
397
- # it carried said retiring it meant changing how references are STORED ;
398
- # that is what happened.
696
+ # A reference is the id itself — refused at the payload gate if it
697
+ # arrives as anything else — so there is nothing to open inside it.
399
698
 
400
- # A REFERENCE IS AN ID, SO ANYTHING ELSE IS NOT ONE.
699
+ # A reference is an ID, so anything else is not one.
401
700
  #
402
701
  # Nothing coerces a reference — `for_attribute` misses on
403
702
  # "Reference<Account>", which is no value object's name, and hands the
@@ -406,14 +705,14 @@ module Hecks
406
705
  # refused, so whatever the first caller wrote became the shape.
407
706
  #
408
707
  # This is that place. It sits at the payload gate rather than inside
409
- # coercion because the sentence names the COMMAND, and `for_attribute`
708
+ # coercion because the sentence names the command, and `for_attribute`
410
709
  # never learns which command it is serving.
411
710
  #
412
- # WIDENED PAST THE OBJECT SHAPE BY BUG#27 (QualityControl ledger,
711
+ # Widened past the object shape by BUG#27 (QualityControl ledger,
413
712
  # found live on `qa/stress_domains/referral_chain`'s `Member.Join`/
414
- # `Referral.Issue`). A bare Boolean, Array, or `null` used to sail
415
- # through here untouched — nothing but Hash/Value ever refused —
416
- # then get `.to_s`'d into a lookup key by `CommandRules::
713
+ # `Referral.Issue`). Without this widening, a bare Boolean, Array, or
714
+ # `null` would sail through here untouched — nothing but Hash/Value
715
+ # ever refused — then get `.to_s`'d into a lookup key by `CommandRules::
417
716
  # References#reference_key` ("true", "false", "[8, 8]") and answer
418
717
  # NotFound, or, for `null`, skip the lookup outright
419
718
  # (`next if held.nil?`, command_rules/references.rb) and let the
@@ -426,26 +725,36 @@ module Hecks
426
725
  # (`normalize_args`, `Vocabulary::AggregateDispatchOrder`/
427
726
  # `EntityDispatchOrder`), strictly before `resolve_references` ever
428
727
  # receives a value to look up — so the two engines now agree on
429
- # BOTH kind and order, not just kind.
728
+ # both kind and order, not just kind.
430
729
  #
431
- # `nil` STAYS LEGITIMATE for a `reference_to ..., optional: true`
730
+ # `nil` stays legitimate for a `reference_to ..., optional: true`
432
731
  # argument (`Improvement.Open`'s own `reference_to Angle, optional:
433
732
  # true` — `qa/bluebook/quality_control.bluebook`): the caller
434
733
  # genuinely may have nothing to name yet, and `nil_argument`
435
734
  # (interpreting.rb) already passes an optional reference's `nil`
436
- # through untouched. A REQUIRED reference offered as `null` is a
437
- # caller leaving a required argument empty in every OTHER sense
438
- # this runtime already refuses (C3.7) — refusing it HERE, rather
735
+ # through untouched. A required reference offered as `null` is a
736
+ # caller leaving a required argument empty in every other sense
737
+ # this runtime already refuses (C3.7) — refusing it here, rather
439
738
  # than falling through to `resolve_references`' own nil-skip and
440
739
  # then whatever the command's `given` happens to say, is what
441
740
  # actually names the empty argument instead of something else.
442
741
  #
443
742
  # A `has_many` reference's own Array shape is still never refused
444
- # by ITS wrapper (`Array(value).find { ... }` only inspects the
445
- # list's ELEMENTS) — a reference is never a scalar list-of-lists
743
+ # by its wrapper (`Array(value).find { ... }` only inspects the
744
+ # list's elements) — a reference is never a scalar list-of-lists
446
745
  # today, and inventing a rule for a shape the language cannot
447
746
  # declare is how decoration gets written. `reference_list` (below)
448
747
  # already owns "not an Array at all" for that case.
748
+ #
749
+ # @param command [Class] the command class (`Bluebook::Command` subclass)
750
+ # `attribute` is declared on, named in a refusal
751
+ # @param attribute [Bluebook::Attribute] the attribute to check; a no-op
752
+ # unless it is reference-typed
753
+ # @param value [Object] the offered value
754
+ # @return [void]
755
+ # @raise [Runtime::TypeMismatch] if `value` (or, for a `has_many` reference,
756
+ # any of its elements) is a Hash or a `Runtime::Value` rather than a plain
757
+ # identity, or a required scalar reference is anything but a String
449
758
  def refuse_object_reference(command, attribute, value)
450
759
  return unless attribute.reference?
451
760
 
@@ -460,19 +769,23 @@ module Hecks
460
769
  end
461
770
 
462
771
  raise TypeMismatch,
463
- RefusalWording.render("TypeMismatch", "reference_wrong_shape",
464
- command: command.hecks_name, attribute: attribute.name,
465
- offered: reference_shape_description(offered),
466
- known_by: known_by(attribute))
772
+ RefusalWording.render_site("TypeMismatch", "reference_wrong_shape",
773
+ command: command.hecks_name, attribute: attribute.name,
774
+ offered: reference_shape_description(offered),
775
+ known_by: known_by(attribute))
467
776
  end
468
777
 
469
- # "an object" for the Hash/Value shape — the ORIGINAL wording this
778
+ # "an object" for the Hash/Value shape — the original wording this
470
779
  # method always gave, pinned byte for byte by
471
780
  # `spec/runtime/reference_shape_spec.rb`, kept unchanged by BUG#27's
472
781
  # widening. `Rendering.describe` for everything else: `true`,
473
782
  # `false`, `nil`, `[8, 8]` — the same rendering every other
474
783
  # TypeMismatch in this file already uses for "here is what you
475
784
  # actually sent."
785
+ #
786
+ # @param value [Object] the wrongly-shaped offered value to describe
787
+ # @return [String] `"an object"` for a Hash or `Runtime::Value`; otherwise
788
+ # `Rendering.describe(value)`
476
789
  def reference_shape_description(value)
477
790
  return "an object" if value.is_a?(Hash) || value.is_a?(self)
478
791
 
@@ -485,11 +798,16 @@ module Hecks
485
798
  # article-choosing rule. Silent when the target is another chapter's,
486
799
  # where this runtime cannot see what it is known by.
487
800
  #
488
- # EVERY HEAD, because a caller has to pass every one. This read
489
- # `identified_by`, which is the SINGLE head and is nil the moment an
801
+ # Every head, because a caller has to pass every one. This read
802
+ # `identified_by`, which is the single head and is nil the moment an
490
803
  # identity has two parts — so a composite target fell through the guard
491
804
  # and the refusal went silent exactly where it had the most to say. A
492
805
  # single-path target reads as it always did.
806
+ #
807
+ # @param attribute [Bluebook::Attribute] the reference-typed attribute to
808
+ # describe the target's own identity heads for
809
+ # @return [String] `" (Target is known by head1, head2)"`, or `""` when the
810
+ # target cannot be resolved or declares no identity heads
493
811
  def known_by(attribute)
494
812
  heads = Array(attribute.type.resolve&.identity_heads)
495
813
  return "" if heads.empty?
@@ -497,15 +815,37 @@ module Hecks
497
815
  " (#{attribute.type.target_name} is known by #{heads.join(', ')})"
498
816
  end
499
817
 
818
+ # Renders a value object into the bare scalar its one field holds — for a
819
+ # column or a message, where there is no path to consult.
820
+ #
821
+ # @param value [Object] the value to render; passed through unless a
822
+ # `Runtime::Value`
823
+ # @return [Object] `value` unchanged when it is not a `Runtime::Value`;
824
+ # otherwise its one field's own value
825
+ # @raise [Runtime::TypeMismatch] if `value` is a `Runtime::Value` with more
826
+ # than one field
500
827
  def scalar(value)
501
828
  return value unless value.is_a?(self)
502
829
 
503
830
  fields = value.to_h
504
831
  return fields.values.first if fields.size == 1
505
832
 
506
- raise TypeMismatch, RefusalWording.render("TypeMismatch", "multi_field_scalar", type: value.type_name)
833
+ raise TypeMismatch, RefusalWording.render_site("TypeMismatch", "multi_field_scalar", type: value.type_name)
507
834
  end
508
835
 
836
+ # Coerces a derived identity string back into `attribute`'s own declared type.
837
+ #
838
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity] the construct
839
+ # `attribute` is declared on
840
+ # @param attribute [Bluebook::Attribute] the identity attribute to coerce
841
+ # `identifier` for
842
+ # @param identifier [String, Object] the derived identity, typically a
843
+ # String (`Identity.of`/`Identity.from`'s own return)
844
+ # @return [Runtime::Value, String, Object] a built value object when
845
+ # `attribute`'s type names a single-field value object; `identifier`
846
+ # unchanged otherwise
847
+ # @raise [Runtime::TypeMismatch] if `attribute`'s type names a value object
848
+ # with more than one field
509
849
  def from_identifier(aggregate, attribute, identifier)
510
850
  value_object = value_object_for(aggregate, attribute.type)
511
851
  return identifier unless value_object
@@ -516,29 +856,29 @@ module Hecks
516
856
  return build(value_object, { field.name => coerce_identifier(field, identifier) })
517
857
  end
518
858
 
519
- raise TypeMismatch, RefusalWording.render("TypeMismatch", "composite_identity", type: value_object.hecks_name)
859
+ raise TypeMismatch, RefusalWording.render_site("TypeMismatch", "composite_identity", type: value_object.hecks_name)
520
860
  end
521
861
 
522
862
  # Vendored fix, not (yet) upstream hecks (migration plan
523
- # task 9): `identifier` here is always the DERIVED IDENTITY
524
- # STRING -- `Identity.of`/`Identity.from` intentionally return
863
+ # task 9): `identifier` here is always the derived identity
864
+ # string -- `Identity.of`/`Identity.from` intentionally return
525
865
  # one (correct for naming a repository key), and
526
866
  # `Runtime::Instance#materialize_identity!` calls `from_identifier`
527
867
  # with exactly that string on every fresh hydration -- but when
528
- # the identity field's OWN declared type is Integer/Float (not
868
+ # the identity field's own declared type is Integer/Float (not
529
869
  # the overwhelmingly common String), seeding it straight from
530
870
  # that string round-trips a correctly-derived identity back in
531
- # as the WRONG Ruby type -- and #build's own
871
+ # as the wrong Ruby type -- and #build's own
532
872
  # `check_numeric_fields` (added specifically to catch a genuine
533
- # CALLER mismatch) then refused the runtime's OWN internal
873
+ # caller mismatch) then refused the runtime's own internal
534
874
  # identity seed instead, on every dispatch, valid input or not.
535
875
  #
536
- # Reuses THIS SAME FILE's own `NUMERIC` table (declared-type ->
876
+ # Reuses this same file's own `NUMERIC` table (declared-type ->
537
877
  # expected-Ruby-class, already read by `check_numeric_fields`)
538
- # to decide WHICH declared types need converting, and
878
+ # to decide which declared types need converting, and
539
879
  # Kernel#Integer/#Float to do the converting. A genuinely
540
880
  # malformed identifier (should never happen, since an identity
541
- # is always derived FROM a correctly-typed field in the first
881
+ # is always derived from a correctly-typed field in the first
542
882
  # place, but this stays defensive rather than assume it) passes
543
883
  # back unconverted, and `check_numeric_fields` refuses it
544
884
  # exactly as it always has -- preserving its real job of
@@ -556,28 +896,36 @@ module Hecks
556
896
  identifier
557
897
  end
558
898
 
899
+ # Renders a value object's fields as a canonical JSON string, for an
900
+ # invariant refusal to quote.
901
+ #
902
+ # @param fields [Hash{Symbol, String => Object}] the field values to render
903
+ # @return [String] `fields`, sorted by key name and JSON-encoded
559
904
  def canonical_fields(fields)
560
905
  JSON.generate(fields.sort_by { |name, _| name.to_s }.to_h)
561
906
  end
562
907
 
563
- # A field declared Integer or Float must ARRIVE as one.
908
+ # A field declared Integer or Float must arrive as one.
564
909
  #
565
910
  # Without this a String sails into a numeric field and the failure surfaces
566
911
  # later, inside a predicate, as `positive? expects a number, got "three"` —
567
- # an EvaluationError, which is NOT a domain refusal. So the runtime broke
912
+ # an EvaluationError, which is not a domain refusal. So the runtime broke
568
913
  # where the domain should have said no, and the run contract recorded the
569
914
  # crash beside genuine refusals as though the domain had judged it.
570
915
  #
571
916
  # C3.8 — the boundary check for an attribute whose type is a bare
572
917
  # primitive rather than a value object: `Integer`/`Float` by exact
573
- # numeric class (`NUMERIC`), `String`/booleans by rejecting a
574
- # composite shape (`COMPOSITE_SHAPES`) — identical to what
575
- # `check_numeric_fields`/`check_scalar_shapes` hold a value
576
- # object's own fields to, worded by the same template with the
577
- # owning construct as `type`. A `String` field still admits any
578
- # other scalar (the self-hosted grammar's own bootstrap relies on
579
- # it — see `check_scalar_shapes`' comment); the Rust boundary is
580
- # stricter there, recorded in the clause.
918
+ # numeric class (`NUMERIC`), `String`/booleans by rejecting only a
919
+ # composite shape (`COMPOSITE_SHAPES`) — not the same as
920
+ # `check_scalar_shapes` holds a value object's own `String` field to
921
+ # any more (QualityControl BUG#125 tightened that one to also refuse
922
+ # a non-string scalar; a bare `String` argument here still admits
923
+ # any other scalar, left exactly as it was — a bare-primitive
924
+ # attribute was never part of BUG#125's own investigation or fix,
925
+ # and whether it needs the same tightening, and against what real
926
+ # Judge dependency if any, is still open); worded by the same
927
+ # template with the owning construct as `type`. The Rust boundary is
928
+ # stricter there too, recorded in the clause.
581
929
  private def check_bare_primitive(owner, attribute, value)
582
930
  type = attribute.type.to_s
583
931
  expected = NUMERIC[type]
@@ -590,9 +938,9 @@ module Hecks
590
938
  end
591
939
  if mistyped
592
940
  raise TypeMismatch,
593
- RefusalWording.render("TypeMismatch", "numeric_field",
594
- type: owner.hecks_name, field: attribute.name,
595
- expected: type, offered: Rendering.describe(value))
941
+ RefusalWording.render_site("TypeMismatch", "numeric_field",
942
+ type: owner.hecks_name, field: attribute.name,
943
+ expected: type, offered: Rendering.describe(value))
596
944
  end
597
945
 
598
946
  check_numeric_bounds(owner.hecks_name, attribute.name, value)
@@ -607,41 +955,91 @@ module Hecks
607
955
  private def check_numeric_bounds(type_name, field_name, given)
608
956
  if given.is_a?(Integer) && !INT64_RANGE.cover?(given)
609
957
  raise TypeMismatch,
610
- RefusalWording.render("TypeMismatch", "integer_range",
611
- type: type_name, field: field_name, offered: Rendering.describe(given))
958
+ RefusalWording.render_site("TypeMismatch", "integer_range",
959
+ type: type_name, field: field_name, offered: Rendering.describe(given))
612
960
  end
613
961
  return unless given.is_a?(Float) && !given.finite?
614
962
 
615
963
  raise TypeMismatch,
616
- RefusalWording.render("TypeMismatch", "non_finite_field",
617
- type: type_name, field: field_name, offered: Rendering.describe(given))
964
+ RefusalWording.render_site("TypeMismatch", "non_finite_field",
965
+ type: type_name, field: field_name, offered: Rendering.describe(given))
618
966
  end
619
967
 
620
- # C3.7 — A VALUE OBJECT IS A TYPED FIELD PRODUCT: every non-optional
968
+ # QualityControl BUG#41 — a value object refuses a key it does not
969
+ # declare, the same way a command's own payload does
970
+ # (`CommandInterpreter::ArgumentGate#refuse_unknown_arguments`,
971
+ # argument_gate.rb) — reusing that method's exact refusal wording
972
+ # (`UnknownArgument unknown_args`, refusal_wording.rb:
973
+ # "{command} does not declare {unknown} — it takes {declared}")
974
+ # rather than inventing a new template, because every generated
975
+ # Rust value-object `from_json` already renders this refusal
976
+ # through that identical site: `rust/project/json_codec.rb`'s
977
+ # `emit_unknown_argument_check` (mirrored byte-for-byte in
978
+ # `rust/codegen/src/json_codec.rs`) emits `v.unknown_keys(&[...])`
979
+ # and the same "{name} does not declare {unknown} — it takes
980
+ # {declared}" format string for every value object's own
981
+ # `from_json` — `GameLabel::from_json`
982
+ # (rust/src/generated/chess/game.rs) is simply the first case
983
+ # this gap was reproduced against.
984
+ #
985
+ # Ruby never had an equivalent check anywhere in this `validate!`
986
+ # door before now — `fields[attribute.name]` reads only the
987
+ # declared attributes, so any other key a caller's Hash carried
988
+ # was silently ignored. `fields` here only ever holds what a
989
+ # caller (or `for_attribute`'s own recursive coercion) offered
990
+ # for this value object — built by `fields_for`'s plain
991
+ # key-symbolizing (never a Hash the runtime pads with bookkeeping
992
+ # keys of its own; confirmed by reading every call site that
993
+ # reaches `validate!`) — so there is nothing legitimate here to
994
+ # exempt.
995
+ #
996
+ # Checked first, before `check_required_fields` and everything
997
+ # after it — matching Rust's own `from_json`, which checks
998
+ # `unknown_keys` before reading a single declared field. So a
999
+ # Hash offering both an unrecognized key and a missing required
1000
+ # one (BUG#41's own second demonstration case: `label: {extra:
1001
+ # "bogus"}` — unknown and missing `value`) refuses the same
1002
+ # UnknownArgument on both engines, not two different refusal
1003
+ # kinds for one malformed call.
1004
+ private def check_unknown_fields(value_object, fields)
1005
+ known = value_object.attributes.map { |attribute| attribute.name.to_sym }
1006
+ unknown = (fields.keys.map(&:to_sym) - known).sort
1007
+ return if unknown.empty?
1008
+
1009
+ declared = value_object.attributes.map(&:name)
1010
+ raise UnknownArgument,
1011
+ RefusalWording.render_site("UnknownArgument", "unknown_args",
1012
+ command: value_object.hecks_name, unknown: unknown,
1013
+ declared: declared)
1014
+ end
1015
+
1016
+ # C3.7 — a value object is a typed field product: every non-optional
621
1017
  # field arrives, or construction refuses. A missing field and a null
622
1018
  # one are the same absence (`fields[name]` reads nil for both), worded
623
1019
  # as the type mismatch it is — "{type}.{field} expects {expected}, got
624
1020
  # nil" — the identical string the Rust side's generated `from_json`
625
1021
  # gives the same input, so the corpus can pin it on both. Checked
626
- # FIRST: an invariant reading a field that never arrived is exactly
627
- # the thing that used to answer "invariant violated" (or nothing at
628
- # all — `ToppingName`'s `{value: null}` used to be accepted and
629
- # stored). A `default:` has already been filled in by `apply_defaults`;
630
- # a list field's absence is an empty list, never a refusal.
1022
+ # first among the field-content checks (after `check_unknown_fields`'s
1023
+ # own structural gate above, BUG#41): without this, an invariant
1024
+ # reading a field that never arrived would be exactly the thing that
1025
+ # answers "invariant violated" (or nothing at all — `ToppingName`'s
1026
+ # `{value: null}` would be accepted and stored). A `default:` has
1027
+ # already been filled in by `apply_defaults`; a list field's absence
1028
+ # is an empty list, never a refusal.
631
1029
  private def check_required_fields(value_object, fields)
632
1030
  value_object.attributes.each do |attribute|
633
1031
  next if attribute.optional? || attribute.list?
634
1032
  next unless fields[attribute.name].nil?
635
1033
 
636
1034
  raise TypeMismatch,
637
- RefusalWording.render("TypeMismatch", "numeric_field",
638
- type: value_object.hecks_name, field: attribute.name,
639
- expected: attribute.type, offered: "nil")
1035
+ RefusalWording.render_site("TypeMismatch", "numeric_field",
1036
+ type: value_object.hecks_name, field: attribute.name,
1037
+ expected: attribute.type, offered: "nil")
640
1038
  end
641
1039
  end
642
1040
 
643
- # Checked BEFORE invariants, because an invariant reading a mistyped field
644
- # is exactly the thing that used to explode.
1041
+ # Checked before invariants, because an invariant reading a mistyped field
1042
+ # is exactly the thing that would otherwise explode.
645
1043
  NUMERIC = { "Integer" => Integer, "Float" => Numeric }.freeze
646
1044
  private def check_numeric_fields(value_object, fields)
647
1045
  value_object.attributes.each do |attribute|
@@ -653,9 +1051,9 @@ module Hecks
653
1051
 
654
1052
  unless given.is_a?(expected)
655
1053
  raise TypeMismatch,
656
- RefusalWording.render("TypeMismatch", "numeric_field",
657
- type: value_object.hecks_name, field: attribute.name,
658
- expected: attribute.type, offered: Rendering.describe(given))
1054
+ RefusalWording.render_site("TypeMismatch", "numeric_field",
1055
+ type: value_object.hecks_name, field: attribute.name,
1056
+ expected: attribute.type, offered: Rendering.describe(given))
659
1057
  end
660
1058
 
661
1059
  # PRD 05 (numeric-boundary-coverage) — `given.is_a?(expected)`
@@ -673,8 +1071,8 @@ module Hecks
673
1071
  # storage, where `JSON.generate`/`#to_json` raises
674
1072
  # `JSON::GeneratorError: NaN/Infinity not allowed in JSON` the
675
1073
  # moment anything tries to persist or replay it — again a raw
676
- # crash, not a refusal. `-0.0` is deliberately NOT refused
677
- # here: it IS finite, round-trips through JSON as `-0.0`
1074
+ # crash, not a refusal. `-0.0` is deliberately not refused
1075
+ # here: it is finite, round-trips through JSON as `-0.0`
678
1076
  # cleanly (confirmed empirically), and is a legitimate,
679
1077
  # meaningful float value (a signed zero), not a corruption
680
1078
  # risk — only NaN and +/-Infinity are.
@@ -683,45 +1081,60 @@ module Hecks
683
1081
  end
684
1082
 
685
1083
  # A field declared `String` (or a boolean) must not arrive as a
686
- # COMPOSITE — an Array or a Hash (or a nested Value) standing in for
687
- # what has to be a leaf scalar.
688
- #
689
- # Deliberately laxer than `check_numeric_fields` above : it does not
690
- # enforce the exact Ruby class, only that the shape isn't a collection.
691
- # `Judge#v` — the language's own self-hosted grammar validation —
692
- # hands a String-typed field (`Normalise`'s `position`, a `RuleText`)
693
- # a raw Integer walk-index on purpose, on every boot, and that has
694
- # always been tolerated ; a full String-vs-Integer check here would
695
- # refuse the runtime's own bootstrap. But no scalar field, of any
696
- # declared type, can ever legitimately be handed an Array or a Hash —
697
- # that shape is always wrong, and always was: `InvalidValueGenerator#
698
- # array_for_scalar`'s own corruption is deliberately built to be
699
- # REFUSED (see that file's header), and until this check existed it
700
- # sailed straight through for a String/boolean field the way it never
701
- # could for an Integer/Float one (`check_numeric_fields` above already
702
- # catches an Array offered for those). Found live via bin/fuzz, seed
703
- # 17 on the fixtures domain : an Array standing in for a single-field
704
- # identity's declared `String`, `.to_s`'d into a record id downstream.
1084
+ # composite — an Array or a Hash (or a nested Value) standing in for
1085
+ # what has to be a leaf scalar. A `String` field, further, must not
1086
+ # arrive as any other non-composite scalar either (Integer, Float,
1087
+ # true/false) — QualityControl BUG#125, matching Rust's generated
1088
+ # `from_json`, which requires a JSON string node for a String-typed
1089
+ # field unconditionally and refuses anything else, including a JSON
1090
+ # number or boolean. Without this, Ruby would tolerate exactly that
1091
+ # (found live: `Chess::Piece.Capture`'s `PieceId`, String-typed,
1092
+ # offered a bignum `id` — Ruby let it pass and failed later on an
1093
+ # unrelated field, Rust refused on `id` itself, immediately) — except
1094
+ # inside `judge_bootstrapping?` (above), the one caller genuinely
1095
+ # relying on that leniency; see that flag's own comment for why.
1096
+ #
1097
+ # `TrueClass`/`FalseClass` stay laxer than `check_numeric_fields`
1098
+ # above: for those two, this still only enforces that the shape
1099
+ # isn't a collection, not the exact Ruby class — narrower than the
1100
+ # `String` case above because BUG#125 investigated and fixed String
1101
+ # specifically; a boolean field's own scalar-shape tolerance is a
1102
+ # separate, uninvestigated question left exactly as it was. No
1103
+ # scalar field, of any declared type, can ever legitimately be
1104
+ # handed an Array or a Hash — that shape is always wrong, and always
1105
+ # was: `InvalidValueGenerator#array_for_scalar`'s own corruption is
1106
+ # deliberately built to be refused (see that file's header), and
1107
+ # until this check existed it sailed straight through for a
1108
+ # String/boolean field the way it never could for an Integer/Float
1109
+ # one (`check_numeric_fields` above already catches an Array offered
1110
+ # for those). Found live via bin/fuzz, seed 17 on the fixtures
1111
+ # domain : an Array standing in for a single-field identity's
1112
+ # declared `String`, `.to_s`'d into a record id downstream.
705
1113
  COMPOSITE_SHAPES = [Array, ::Hash].freeze
706
1114
  NON_NUMERIC_SCALARS = %w[String TrueClass FalseClass].freeze
707
1115
  private def check_scalar_shapes(value_object, fields)
708
1116
  value_object.attributes.each do |attribute|
709
- next unless NON_NUMERIC_SCALARS.include?(attribute.type.to_s)
1117
+ type = attribute.type.to_s
1118
+ next unless NON_NUMERIC_SCALARS.include?(type)
710
1119
 
711
1120
  given = fields[attribute.name]
712
- next if given.nil? || COMPOSITE_SHAPES.none? { |shape| given.is_a?(shape) }
1121
+ next if given.nil?
1122
+
1123
+ composite = COMPOSITE_SHAPES.any? { |shape| given.is_a?(shape) }
1124
+ non_string_scalar = type == "String" && !composite && !given.is_a?(String) && !judge_bootstrapping?
1125
+ next unless composite || non_string_scalar
713
1126
 
714
1127
  raise TypeMismatch,
715
- RefusalWording.render("TypeMismatch", "numeric_field",
716
- type: value_object.hecks_name, field: attribute.name,
717
- expected: attribute.type, offered: Rendering.describe(given))
1128
+ RefusalWording.render_site("TypeMismatch", "numeric_field",
1129
+ type: value_object.hecks_name, field: attribute.name,
1130
+ expected: attribute.type, offered: Rendering.describe(given))
718
1131
  end
719
1132
  end
720
1133
 
721
- # A field declared with a PATTERN must match it.
1134
+ # A field declared with a pattern must match it.
722
1135
  #
723
1136
  # Beside check_numeric_fields and for the same reason : a value that does
724
- # not look like what it claims to be is the DOMAIN saying no, and it should
1137
+ # not look like what it claims to be is the domain saying no, and it should
725
1138
  # say so here rather than let the wrong shape travel on and surface as a
726
1139
  # broken predicate later.
727
1140
  #
@@ -738,9 +1151,9 @@ module Hecks
738
1151
  next if given.is_a?(String) && Regexp.new(pattern).match?(given)
739
1152
 
740
1153
  raise TypeMismatch,
741
- RefusalWording.render("TypeMismatch", "pattern_mismatch",
742
- type: value_object.hecks_name, field: attribute.name,
743
- pattern: pattern, offered: Rendering.describe(given))
1154
+ RefusalWording.render_site("TypeMismatch", "pattern_mismatch",
1155
+ type: value_object.hecks_name, field: attribute.name,
1156
+ pattern: pattern, offered: Rendering.describe(given))
744
1157
  end
745
1158
  end
746
1159
  end