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