hecks 1.3.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 (355) 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 +5 -4
  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/prism.rb +4 -4
  23. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  24. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  25. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  26. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  27. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  28. data/lib/hecks/adapters/driven.rb +4 -4
  29. data/lib/hecks/adapters/driving/github_webhook.rb +18 -18
  30. data/lib/hecks/behaviors/dsl.rb +2 -2
  31. data/lib/hecks/behaviors/expectations.rb +23 -23
  32. data/lib/hecks/behaviors/ir.rb +1 -1
  33. data/lib/hecks/behaviors/rspec.rb +1 -1
  34. data/lib/hecks/behaviors/runner.rb +2 -2
  35. data/lib/hecks/behaviors.rb +1 -1
  36. data/lib/hecks/bluebook/aggregate.rb +13 -13
  37. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
  38. data/lib/hecks/bluebook/assembly/build.rb +1 -1
  39. data/lib/hecks/bluebook/assembly/contract.rb +39 -16
  40. data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
  41. data/lib/hecks/bluebook/assembly/marks.rb +18 -18
  42. data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
  43. data/lib/hecks/bluebook/assembly.rb +9 -9
  44. data/lib/hecks/bluebook/attribute.rb +9 -9
  45. data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
  46. data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
  47. data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
  48. data/lib/hecks/bluebook/behaviour/command.rb +23 -23
  49. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -7
  50. data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
  51. data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
  52. data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
  53. data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
  54. data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
  55. data/lib/hecks/bluebook/behaviour/query.rb +1 -1
  56. data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
  57. data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
  58. data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
  59. data/lib/hecks/bluebook/capabilities.rb +27 -0
  60. data/lib/hecks/bluebook/chapter.rb +28 -9
  61. data/lib/hecks/bluebook/command.rb +12 -12
  62. data/lib/hecks/bluebook/domain_port.rb +9 -9
  63. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  64. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  65. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
  66. data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
  67. data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
  68. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
  69. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
  70. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  71. data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
  72. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  73. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  74. data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
  75. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  76. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
  77. data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
  78. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  79. data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
  80. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  81. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  82. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
  83. data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
  84. data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
  85. data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
  86. data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
  87. data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
  88. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  89. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  90. data/lib/hecks/bluebook/entity.rb +11 -11
  91. data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
  92. data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
  93. data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
  94. data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
  95. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
  96. data/lib/hecks/bluebook/expression/resolver.rb +60 -62
  97. data/lib/hecks/bluebook/hexagon.rb +1 -1
  98. data/lib/hecks/bluebook/lifecycle.rb +1 -1
  99. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
  100. data/lib/hecks/bluebook/meta_validator/judge.rb +101 -87
  101. data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
  102. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
  103. data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
  104. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
  105. data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
  106. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
  107. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
  108. data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
  109. data/lib/hecks/bluebook/meta_validator.rb +70 -70
  110. data/lib/hecks/bluebook/model_check.rb +170 -84
  111. data/lib/hecks/bluebook/pattern_subset.rb +9 -9
  112. data/lib/hecks/bluebook/policy.rb +15 -13
  113. data/lib/hecks/bluebook/process_manager.rb +14 -14
  114. data/lib/hecks/bluebook/project_register.rb +6 -6
  115. data/lib/hecks/bluebook/query.rb +4 -4
  116. data/lib/hecks/bluebook/read_model.rb +14 -14
  117. data/lib/hecks/bluebook/reference.rb +8 -8
  118. data/lib/hecks/bluebook/smoke_test.rb +19 -19
  119. data/lib/hecks/bluebook/synthesizer.rb +12 -12
  120. data/lib/hecks/bluebook/translation.rb +4 -4
  121. data/lib/hecks/bluebook/value_object.rb +6 -6
  122. data/lib/hecks/bluebook.rb +2 -2
  123. data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
  124. data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
  125. data/lib/hecks/codemod.rb +36 -35
  126. data/lib/hecks/construct.rb +6 -6
  127. data/lib/hecks/corpus.rb +317 -0
  128. data/lib/hecks/deprecation.rb +95 -0
  129. data/lib/hecks/doc/reference.rb +19 -19
  130. data/lib/hecks/embryonaut_bluebook.rb +11 -11
  131. data/lib/hecks/facade/cli_door.rb +69 -10
  132. data/lib/hecks/facade/cli_runner.rb +105 -24
  133. data/lib/hecks/facade/command_request.rb +23 -0
  134. data/lib/hecks/facade/handle.rb +79 -32
  135. data/lib/hecks/facade/json_door.rb +106 -25
  136. data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
  137. data/lib/hecks/facade/surface/chapter.rb +26 -17
  138. data/lib/hecks/facade/surface.rb +16 -3
  139. data/lib/hecks/facade.rb +15 -4
  140. data/lib/hecks/forms/app.rb +46 -30
  141. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  142. data/lib/hecks/forms/field_renderer.rb +142 -6
  143. data/lib/hecks/forms/field_shape.rb +180 -19
  144. data/lib/hecks/forms/html.rb +51 -7
  145. data/lib/hecks/forms/index_renderer.rb +14 -2
  146. data/lib/hecks/forms/params.rb +120 -23
  147. data/lib/hecks/forms/port_argument.rb +2 -2
  148. data/lib/hecks/forms/query_form_renderer.rb +2 -2
  149. data/lib/hecks/forms/record_renderer.rb +2 -2
  150. data/lib/hecks/forms/record_table.rb +1 -1
  151. data/lib/hecks/forms/value_object_shape.rb +3 -3
  152. data/lib/hecks/forms.rb +24 -4
  153. data/lib/hecks/fqn.rb +1 -1
  154. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  155. data/lib/hecks/framework.rb +48 -17
  156. data/lib/hecks/freezer.rb +11 -11
  157. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
  158. data/lib/hecks/fuzzing/combination_miner.rb +119 -0
  159. data/lib/hecks/fuzzing/concurrent_dispatch.rb +140 -45
  160. data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
  161. data/lib/hecks/fuzzing/differential.rb +158 -0
  162. data/lib/hecks/fuzzing/domain_generator.rb +694 -0
  163. data/lib/hecks/fuzzing/era_boundary.rb +37 -18
  164. data/lib/hecks/fuzzing/form_census.rb +35 -20
  165. data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
  166. data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
  167. data/lib/hecks/fuzzing/isolated_boot.rb +62 -62
  168. data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
  169. data/lib/hecks/fuzzing/persistence_parity.rb +19 -21
  170. data/lib/hecks/fuzzing/properties/corrections.rb +9 -9
  171. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +154 -49
  172. data/lib/hecks/fuzzing/properties/guards.rb +42 -42
  173. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
  174. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +22 -35
  175. data/lib/hecks/fuzzing/properties/outbox.rb +24 -24
  176. data/lib/hecks/fuzzing/properties/querying.rb +17 -17
  177. data/lib/hecks/fuzzing/properties.rb +64 -40
  178. data/lib/hecks/fuzzing/qa_settings.rb +152 -0
  179. data/lib/hecks/fuzzing/replay.rb +114 -114
  180. data/lib/hecks/fuzzing/rotation_priority.rb +10 -10
  181. data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
  182. data/lib/hecks/fuzzing/self_consistency.rb +141 -112
  183. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +35 -35
  184. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  185. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  186. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  187. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +58 -31
  188. data/lib/hecks/fuzzing/sequence_generator.rb +111 -23
  189. data/lib/hecks/fuzzing/shrinker.rb +197 -0
  190. data/lib/hecks/fuzzing/structural_skips.rb +23 -130
  191. data/lib/hecks/fuzzing/sweep_depth.rb +4 -4
  192. data/lib/hecks/fuzzing/target_capabilities.rb +49 -18
  193. data/lib/hecks/fuzzing/value_generator.rb +14 -14
  194. data/lib/hecks/fuzzing.rb +5 -0
  195. data/lib/hecks/grammar/evolve.rb +10 -10
  196. data/lib/hecks/grammar.rb +7 -7
  197. data/lib/hecks/ir.rb +13 -13
  198. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  199. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  200. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  201. data/lib/hecks/language/oidc.json +5 -0
  202. data/lib/hecks/literal.rb +9 -9
  203. data/lib/hecks/naming.rb +25 -25
  204. data/lib/hecks/ports/access_control.rb +58 -2
  205. data/lib/hecks/ports/agent/answers.rb +83 -6
  206. data/lib/hecks/ports/agent.rb +119 -35
  207. data/lib/hecks/ports/authentication.rb +44 -4
  208. data/lib/hecks/ports/authorization.rb +53 -11
  209. data/lib/hecks/ports/clock.rb +42 -23
  210. data/lib/hecks/ports/extraction.rb +16 -0
  211. data/lib/hecks/ports/identity_assignment.rb +24 -2
  212. data/lib/hecks/ports/identity_generation.rb +17 -3
  213. data/lib/hecks/ports/identity_resolution.rb +18 -1
  214. data/lib/hecks/ports/loading.rb +4 -0
  215. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  216. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  217. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  218. data/lib/hecks/ports/persistence/execution.rb +4 -0
  219. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  220. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  221. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  222. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  223. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  224. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  225. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  226. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  227. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  228. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  229. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  230. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  231. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  241. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  242. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  243. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  244. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
  245. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  246. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  247. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  248. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
  249. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
  250. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
  251. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  252. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  253. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  254. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  255. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  256. data/lib/hecks/ports/persistence.rb +36 -1
  257. data/lib/hecks/ports/projection.rb +61 -7
  258. data/lib/hecks/ports/query/in_memory.rb +3 -3
  259. data/lib/hecks/ports/query/ordering.rb +6 -6
  260. data/lib/hecks/ports/query.rb +35 -0
  261. data/lib/hecks/projections/bootstrap_table.rb +112 -0
  262. data/lib/hecks/projections/diagrams.rb +75 -75
  263. data/lib/hecks/projections/glossary/html.rb +3 -3
  264. data/lib/hecks/projections/glossary/markdown.rb +3 -3
  265. data/lib/hecks/projections/glossary/mermaid.rb +1 -1
  266. data/lib/hecks/projections/glossary/sections.rb +1 -1
  267. data/lib/hecks/projections/glossary/sentences.rb +5 -5
  268. data/lib/hecks/projections/glossary.rb +15 -15
  269. data/lib/hecks/projections/ir.rb +1 -1
  270. data/lib/hecks/projections/model/deviations.rb +18 -17
  271. data/lib/hecks/projections/model.rb +25 -21
  272. data/lib/hecks/projections/oidc.rb +7 -7
  273. data/lib/hecks/projections/parser_table.rb +5 -5
  274. data/lib/hecks/projections/reference.rb +3 -3
  275. data/lib/hecks/projections/rust_vocabulary.rb +443 -0
  276. data/lib/hecks/projections/shape.rb +2 -2
  277. data/lib/hecks/projections/statements.rb +11 -11
  278. data/lib/hecks/projections/vocabulary.rb +9 -9
  279. data/lib/hecks/projections.rb +5 -3
  280. data/lib/hecks/projector/cli_projector.rb +29 -29
  281. data/lib/hecks/projector/docs_projector.rb +13 -13
  282. data/lib/hecks/projector/exporter.rb +42 -21
  283. data/lib/hecks/projector/ir_projector.rb +1 -1
  284. data/lib/hecks/projector/narrate_projector.rb +11 -11
  285. data/lib/hecks/projector/target.rb +13 -13
  286. data/lib/hecks/projector.rb +15 -15
  287. data/lib/hecks/query_ir.rb +47 -47
  288. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  289. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  290. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  291. data/lib/hecks/query_specification/common/null_policy.rb +57 -13
  292. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  293. data/lib/hecks/query_specification/common/options.rb +25 -0
  294. data/lib/hecks/query_specification/field_path.rb +69 -15
  295. data/lib/hecks/query_specification/hop_path.rb +57 -20
  296. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  297. data/lib/hecks/rendering.rb +3 -3
  298. data/lib/hecks/router/namespace_installer.rb +3 -3
  299. data/lib/hecks/router.rb +1 -1
  300. data/lib/hecks/runtime/aggregate_lock.rb +11 -11
  301. data/lib/hecks/runtime/boot_gates.rb +3 -3
  302. data/lib/hecks/runtime/caller.rb +8 -8
  303. data/lib/hecks/runtime/capability_graph.rb +2 -2
  304. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
  305. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +42 -63
  306. data/lib/hecks/runtime/command_interpreter.rb +159 -102
  307. data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
  308. data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
  309. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  310. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  311. data/lib/hecks/runtime/command_rules/references.rb +35 -35
  312. data/lib/hecks/runtime/command_rules.rb +3 -0
  313. data/lib/hecks/runtime/dependency_planning.rb +11 -11
  314. data/lib/hecks/runtime/dispatcher.rb +283 -90
  315. data/lib/hecks/runtime/entity_element.rb +151 -67
  316. data/lib/hecks/runtime/entity_interpreter.rb +108 -82
  317. data/lib/hecks/runtime/errors.rb +19 -19
  318. data/lib/hecks/runtime/event.rb +6 -6
  319. data/lib/hecks/runtime/identity.rb +22 -22
  320. data/lib/hecks/runtime/instance.rb +39 -14
  321. data/lib/hecks/runtime/interpreting.rb +12 -12
  322. data/lib/hecks/runtime/invocation.rb +276 -0
  323. data/lib/hecks/runtime/loader.rb +14 -14
  324. data/lib/hecks/runtime/outbox.rb +23 -23
  325. data/lib/hecks/runtime/policy_interpreter.rb +54 -54
  326. data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
  327. data/lib/hecks/runtime/query_interpreter.rb +55 -64
  328. data/lib/hecks/runtime/reaction_invocation.rb +20 -20
  329. data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
  330. data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
  331. data/lib/hecks/runtime/reference_hop.rb +6 -6
  332. data/lib/hecks/runtime/refusal_wording.rb +92 -115
  333. data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
  334. data/lib/hecks/runtime/registry/verification.rb +36 -26
  335. data/lib/hecks/runtime/registry.rb +45 -28
  336. data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
  337. data/lib/hecks/runtime/routing.rb +10 -153
  338. data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
  339. data/lib/hecks/runtime/saga_interpreter.rb +64 -64
  340. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  341. data/lib/hecks/runtime/tenant_check.rb +9 -9
  342. data/lib/hecks/runtime/tenant_scope.rb +5 -5
  343. data/lib/hecks/runtime/value/admission.rb +75 -30
  344. data/lib/hecks/runtime/value/coercion.rb +318 -127
  345. data/lib/hecks/runtime/value/entity_list_coercion.rb +34 -34
  346. data/lib/hecks/runtime/value.rb +21 -21
  347. data/lib/hecks/runtime.rb +7 -7
  348. data/lib/hecks/storehouse.rb +64 -64
  349. data/lib/hecks/version.rb +3 -3
  350. data/lib/hecks/vocabulary.rb +205 -4
  351. data/lib/hecks.rb +13 -11
  352. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
  353. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
  354. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
  355. metadata +22 -2
@@ -7,8 +7,8 @@ require_relative "../../runtime/value"
7
7
  module Hecks
8
8
  module Adapters
9
9
  # The one SQL query compilation, shared by the SQLite and Postgres
10
- # adapters — each used to carry its own copy of this walk, near-line-
11
- # identical, and the copies could only drift. Every declared operator
10
+ # adapters — one walk rather than a near-line-identical copy per
11
+ # adapter, because copies can only drift. Every declared operator
12
12
  # compiles fully into SQL or the query refuses loudly; the null-policy
13
13
  # predicate, the comma-separated `in` convention, and the identity
14
14
  # ORDER BY fallback are spelled once, here.
@@ -17,7 +17,7 @@ module Hecks
17
17
  # supplies exactly those hooks:
18
18
  #
19
19
  # placeholder(binds, value) — "?" or "$N", recording the bind
20
- # contains_clause(expr, ph) — instr() or position(), for a SCALAR field
20
+ # contains_clause(expr, ph) — instr() or position(), for a scalar field
21
21
  # list_contains_clause(name, member, ph) — real element containment,
22
22
  # for a `list_of` field (json_each /
23
23
  # jsonb_array_elements)
@@ -33,6 +33,18 @@ module Hecks
33
33
  "eq" => "=", "ne" => "<>", "gt" => ">", "gte" => ">=", "lt" => "<", "lte" => "<="
34
34
  }.freeze
35
35
 
36
+ # Compiles a declared query into one SQL statement and runs it through the dialect's
37
+ # own `execute_query`, so filtering, ordering and paging all happen in the database.
38
+ #
39
+ # @param declared [QuerySpecification::Common::Options] the declared query: its
40
+ # `wheres`, `order_by`, `limit` and `offset` are compiled; nothing else is read
41
+ # @param args [Hash{Symbol => Object}] values for the specification's symbolic operands
42
+ # @param context [Hash] execution context from `Ports::Query.execute`; accepted for the
43
+ # port's call shape and not read
44
+ # @return [Array<Runtime::Instance>] the matching records in declared order, then by
45
+ # id; `[]` when none match
46
+ # @raise [ArgumentError] if a where clause uses an operator this builder cannot compile,
47
+ # or `contains` targets a list of a value object with more than one field
36
48
  def query(declared, args = {}, context: {})
37
49
  sql = "SELECT #{select_list} FROM #{from_relation}"
38
50
  binds = []
@@ -91,7 +103,7 @@ module Hecks
91
103
  members = in_members(value)
92
104
  return empty_in_clause if members.empty?
93
105
 
94
- # BOTH SIDES AS TEXT. `in` is a textual reading everywhere else
106
+ # **Both sides as text**. `in` is a textual reading everywhere else
95
107
  # — Ports::Query::InMemory#holds? compares `held.to_s` against
96
108
  # stringified members, and `in_members` above stringifies its
97
109
  # own — so a numeric field was the one shape where the engines
@@ -104,7 +116,7 @@ module Hecks
104
116
  # on a String, an Integer, and a value-object member.
105
117
  #
106
118
  # `eq`/`gt`/`lt` never had this problem because they bind the
107
- # value's OWN type and route through `comparable_expression`,
119
+ # value's own type and route through `comparable_expression`,
108
120
  # which Postgres overrides to cast numerics.
109
121
  "CAST(#{expression} AS TEXT) IN (#{members.map { |member| placeholder(binds, member) }.join(', ')})"
110
122
  else
@@ -112,16 +124,16 @@ module Hecks
112
124
  end
113
125
  end
114
126
 
115
- # A REAL ARRAY ALREADY SAYS WHERE ITS MEMBERS END. Splitting one on
127
+ # A real array already says where its members end. Splitting one on
116
128
  # commas re-reads a boundary it already drew — and an id is a
117
129
  # domain value (Naming::IDENTITY_JOIN joins a composite identity's
118
130
  # own parts, and the parts it joins are whatever an identity path
119
131
  # pulled out of real data), so a name carrying a comma would
120
132
  # silently become two members matching the wrong rows, or nothing.
121
- # This branch used to call `value.to_s` unconditionally, which
122
- # meant only a comma-joined STRING ever worked here, while
133
+ # Calling `value.to_s` unconditionally would mean only a
134
+ # comma-joined string ever works here, while
123
135
  # Ports::Query::InMemory and QueryInterpreter's own `members`
124
- # already handled a real Array — three implementations of `in`,
136
+ # handle a real Array — three implementations of `in`,
125
137
  # two readings of it. Narrower than those two on one point,
126
138
  # deliberately: they unwrap a value-object/Hash element to its
127
139
  # own scalar first (`comparable`) before stringifying, a rule
@@ -140,15 +152,15 @@ module Hecks
140
152
  # member the bound value (or the declared types) say is numeric,
141
153
  # falling back to the one-field convention `value`.
142
154
  #
143
- # A REFERENCE IS AN ID, stored as a bare scalar (never wrapped —
155
+ # A reference is an ID, stored as a bare scalar (never wrapped —
144
156
  # `Runtime::Value.refuse_object_reference` guarantees it), so it
145
- # takes the SAME plain-column path as any other non-value-object
157
+ # takes the same plain-column path as any other non-value-object
146
158
  # attribute. Excluding it into the value-object member-picking logic
147
159
  # compiles a nested read against a row that has no nested key — a
148
160
  # path that can never match. Measured, not assumed: a `where` on a
149
161
  # has_one/belongs_to/reference_to field returned zero rows against
150
162
  # real data until that exclusion was removed.
151
- # `member` resolves through two ORDERED fallback tiers — the bound
163
+ # `member` resolves through two ordered fallback tiers — the bound
152
164
  # value's own numeric field first, then the declared value object's
153
165
  # numeric-or-sole attribute — each documented above as fixing a
154
166
  # real, measured bug (a reference field matching nothing, a
@@ -168,7 +180,7 @@ module Hecks
168
180
  member = if path.empty? && value
169
181
  hash = value.is_a?(Runtime::Value) ? value.to_h : value
170
182
  numeric = hash.is_a?(Hash) && hash.find { |_key, item| item.is_a?(Numeric) }
171
- # NOT &.-able: `numeric` can be `false` (hash.is_a?(Hash) came
183
+ # Not &.-able: `numeric` can be `false` (hash.is_a?(Hash) came
172
184
  # back false) as well as nil (.find came back empty) — `&.`
173
185
  # only guards nil, so `false.first` raises. False positive.
174
186
  # rubocop:disable-next Style/SafeNavigation
@@ -176,8 +188,8 @@ module Hecks
176
188
  end
177
189
  member ||= if path.empty? && attribute && value_object?(attribute)
178
190
  object = @aggregate.value_object(attribute.type)
179
- # A SOLE attribute is the fallback when no member is
180
- # numeric — a single-attribute value object IS its one
191
+ # A sole attribute is the fallback when no member is
192
+ # numeric — a single-attribute value object is its one
181
193
  # field whatever that field is named (`Behaviour::
182
194
  # ValueObject#sole_attribute`, the same strict rule
183
195
  # `Runtime::Value`'s own `.value` alias enforces), so
@@ -192,11 +204,11 @@ module Hecks
192
204
  nested_expression(name, path, member)
193
205
  end
194
206
 
195
- # A NUMERIC MEMBER WINS if the value object has one (Price, Money —
207
+ # A numeric member wins if the value object has one (Price, Money —
196
208
  # what every ordered comparison in the corpus until now compared),
197
- # otherwise the SAME single-field fallback `query_expression`'s own
209
+ # otherwise the same single-field fallback `query_expression`'s own
198
210
  # `nested_expression(name, path, member || "value")` already
199
- # makes for the COLUMN side — a plain `attribute :value, String`
211
+ # makes for the column side — a plain `attribute :value, String`
200
212
  # value object (IdentityId, RoleName, ...) has no numeric member at
201
213
  # all, and returning nil there silently turned an equality
202
214
  # comparison into `IS NULL`, matching nothing. Both sides of one
@@ -221,16 +233,16 @@ module Hecks
221
233
  !attr.list? && !@aggregate.value_object(attr.type).nil?
222
234
  end
223
235
 
224
- # `contains` on a `list_of` field means real ELEMENT membership, not
236
+ # `contains` on a `list_of` field means real element membership, not
225
237
  # a substring search over the column's raw JSON text — the reading
226
238
  # QueryInterpreter#holds? and Ports::Query::InMemory already give a
227
- # real Array, and the SQL side used to disagree with it (a substring
228
- # match over `[{"value":"not_high_risk"}]` falsely matches
239
+ # real Array, and a substring search would disagree with it (a
240
+ # substring match over `[{"value":"not_high_risk"}]` falsely matches
229
241
  # "high_risk"). Returns nil for a non-list field (the caller falls
230
242
  # back to `contains_clause`, unchanged), "" for a list of bare
231
243
  # scalars (no member to walk into), or the one field name a
232
244
  # single-field value-object element carries. A list of a
233
- # MULTI-field value object has no one scalar to compare against and
245
+ # multi-field value object has no one scalar to compare against and
234
246
  # is refused rather than guessed.
235
247
  def list_member(field)
236
248
  return nil if field.to_s.include?(".")
@@ -16,7 +16,7 @@ module Hecks
16
16
  fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && fields.none? do |field|
17
17
  field[:name] == lifecycle.field
18
18
  end
19
- # `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO
19
+ # `projects` fields (S12, ADR 0025) are a local column too
20
20
  # `CommandInterpreter#seed_projected_fields`/`RebuildSweep`
21
21
  # both write one straight into `Instance#state` the same as
22
22
  # any other field, so a column has to exist to hold it or
@@ -37,15 +37,15 @@ module Hecks
37
37
  end
38
38
 
39
39
  def encode(attr, value)
40
- # NEVER SET IS NOT EMPTY. `then_set ... append:` starts a list at `[]`
40
+ # **Never set is not empty**. `then_set ... append:` starts a list at `[]`
41
41
  # the moment the first element lands, but a list attribute nothing has
42
- # ever appended to — the shape every OTHER aggregate in the corpus
42
+ # ever appended to — the shape every other aggregate in the corpus
43
43
  # happened not to carry, until one declared a list-typed head
44
44
  # attribute that no creating command populates by default — has to
45
45
  # stay NULL to answer the same as Memory does. Forcing it to `[]`
46
46
  # here is how a persistence topology invented data no dispatch wrote.
47
- return (value.nil? ? nil : JSON.generate(value)) if attr.list?
48
- return JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Runtime::Value)
47
+ return (value.nil? ? nil : state_json(value)) if attr.list?
48
+ return state_json(value) if value.is_a?(Hash) || value.is_a?(Runtime::Value)
49
49
 
50
50
  value
51
51
  end
@@ -56,25 +56,53 @@ module Hecks
56
56
  encode(field[:attribute], value)
57
57
  end
58
58
 
59
+ # Every JSON column's text goes through the state codec's `encode`
60
+ # (PR A3) — string keys at every depth, Values materialized.
61
+ def state_json(value) = JSON.generate(Ports::Persistence::StateCodec.encode(@aggregate, value))
62
+
63
+ # The row's columns reassembled into the stored state, then decoded
64
+ # once through the state codec (PR A3) — the same deep, IR-driven
65
+ # key spelling every other adapter's read now produces, instead of
66
+ # this codec's own `symbolize_names:` walk.
59
67
  def decode(row)
60
- persisted_fields.each_with_object({}) do |field, state|
68
+ state = persisted_fields.each_with_object({}) do |field, raw_state|
61
69
  attr = field[:attribute]
62
70
  unless attr
63
- state[field[:name]] = row[field[:name].to_s]
71
+ value = row[field[:name].to_s]
72
+ next if value.nil? && projected_only?(field)
73
+
74
+ raw_state[field[:name]] = value
64
75
  next
65
76
  end
66
77
  raw = row[attr.name.to_s]
67
- state[attr.name] =
78
+ raw_state[attr.name] =
68
79
  if attr.list? || value_object?(attr)
69
- raw ? JSON.parse(raw, symbolize_names: true) : nil
80
+ raw ? JSON.parse(raw) : nil
70
81
  else
71
- # A REFERENCE lands here now, with the ordinary scalars. It holds
82
+ # A reference lands here now, with the ordinary scalars. It holds
72
83
  # the id of a head, which is text — `JSON.parse("acct-1")` raises,
73
84
  # so reading it as JSON was only ever survivable while the column
74
85
  # held an object.
75
86
  raw
76
87
  end
77
88
  end
89
+ Ports::Persistence::StateCodec.decode(@aggregate, state)
90
+ end
91
+
92
+ # A NULL `projects` column is a field never seeded, not a stored
93
+ # NIL — so it reads back absent, the way Heki/PostgresEra (one blob
94
+ # holding only what was written) already answer it. A column holds
95
+ # every persisted field whether or not the record ever had one, so
96
+ # NULL is the only spelling "never set" has here; and nothing ever
97
+ # stores a nil projected value to confuse it with —
98
+ # `CommandInterpreter#seed_projected_fields` and
99
+ # `RebuildSweep#refresh` both skip a nil remote value. Absent is also
100
+ # what `RebuildSweep#refresh`'s `record.key?` and
101
+ # `Registry#projection_current?`'s row comparison expect. The
102
+ # lifecycle field (the other `attribute: nil` column) keeps its NULL.
103
+ def projected_only?(field)
104
+ @aggregate.lifecycle&.field&.to_sym != field[:name].to_sym &&
105
+ @aggregate.projected_fields.any? { |projected| projected.name.to_sym == field[:name].to_sym }
78
106
  end
79
107
  end
80
108
  end
@@ -6,7 +6,7 @@ require_relative "../../../rendering"
6
6
  require_relative "../../../ports/query/in_memory"
7
7
 
8
8
  # The subclass needs its parent — and sqlite.rb requires this file at
9
- # its BOTTOM, after class Sqlite is defined, so the require cycle this
9
+ # its bottom, after class Sqlite is defined, so the require cycle this
10
10
  # creates resolves correctly from either entry point.
11
11
  require_relative "../sqlite"
12
12
 
@@ -17,6 +17,13 @@ module Hecks
17
17
  # bare id it holds — the same shapes the command path writes, so there
18
18
  # is no second representation to accept here any more.
19
19
  class SqliteProjection < Sqlite
20
+ # Replaces or deletes the read store's row for one journal entry, encoding the entry's
21
+ # state directly rather than through a `Runtime::Instance`.
22
+ #
23
+ # @param entry [Ports::Persistence::Entry] the save or delete to materialize
24
+ # @return [Ports::Persistence::Entry, Array] the same `entry` for a save; for a delete,
25
+ # the `DELETE` statement's empty result rows
26
+ # @raise [SQLite3::Exception] if the statement fails
20
27
  def project(entry)
21
28
  return @db.execute("DELETE FROM #{quoted_table} WHERE id = ?", [entry.id]) if entry.delete?
22
29
 
@@ -34,36 +41,51 @@ module Hecks
34
41
  # than scanning repositories and matching references in Ruby.
35
42
  #
36
43
  # M19 (docs/audits/2026-08-10-main-bug-audit.md,
37
- # docs/audits/2026-08-11-bug-triage.md) — this used to diverge from
44
+ # docs/audits/2026-08-11-bug-triage.md) — this agrees with
38
45
  # `Runtime::ReadModelInterpreter#project` (the in-process path) on
39
- # two counts, both fixed here to agree with it:
46
+ # the two counts where a native path can most easily diverge:
40
47
  #
41
- # MISSING ROOT: the in-process path's own `fetch` refuses with
42
- # `NotFound` when the reference argument names no record
43
- # `query_read_model` used to answer a silent `{root: nil, ...}`
44
- # instead, the one path a caller could dispatch a read model
48
+ # **Missing root**: the in-process path's own `fetch` refuses with
49
+ # `NotFound` when the reference argument names no record, and so
50
+ # does this. Answering a silent `{root: nil, ...}` instead would
51
+ # make this the one path where a caller could dispatch a read model
45
52
  # against a record that never existed and get back something that
46
- # LOOKS like an empty report rather than the refusal every other
53
+ # looks like an empty report rather than the refusal every other
47
54
  # path gives.
48
55
  #
49
- # CHAINED-INCLUDE JOIN SCOPE: a non-root head was always matched
50
- # against the ROOT's own id, regardless of what it actually
51
- # references correct for a head that references the root
52
- # directly, silently EMPTY for one that references another
56
+ # **Chained-include join scope**: matching a non-root head against
57
+ # the root's own id alone, regardless of what it actually
58
+ # references, is correct for a head that references the root
59
+ # directly and silently empty for one that references another
53
60
  # included head instead (`Leaf` -> `Mid` -> `Root`, `Leaf` itself
54
- # has no attribute referencing `Root` at all, so `references`
55
- # was always `[]`). The in-process path's own root-first fix
56
- # (`ReadModelInterpreter#project`'s "ROOT FIRST, ALWAYS" comment)
57
- # already matches a head against ANY already-projected source, not
58
- # only the root — `select_related` now does the same: each head is
61
+ # has no attribute referencing `Root` at all, so its `references`
62
+ # would always be `[]`). The in-process path
63
+ # (`ReadModelInterpreter#project`'s "root first, always" comment)
64
+ # matches a head against any already-projected source, not
65
+ # only the root — `select_related` does the same: each head is
59
66
  # matched against every source resolved so far (root first, then
60
67
  # declared order — the same one-level-of-declaration-order
61
- # dependency the in-process path itself still has, documented
68
+ # dependency the in-process path itself has, documented
62
69
  # there as L2, not a gap introduced here).
70
+ #
71
+ # @param _domain [String, Symbol] name of the domain declaring the read model; not read
72
+ # @param model [Bluebook::ReadModel, Runtime::TenantScope::Scoped] the read model to
73
+ # answer, or the tenant-scoping delegator around one; must have a reference target
74
+ # @param args [Hash{Symbol => Object}] the read model's arguments; the entry under
75
+ # `model.reference_name` is the root record's id
76
+ # @param bluebook [Bluebook::Chapter, nil] the domain's bluebook, which resolves each
77
+ # included aggregate by name; nil is refused
78
+ # @return [Array<Hash>] a one-element Array holding the report Hash, keyed by each
79
+ # head's `as` name: an Array of plain state Hashes (each with `:id`) for a `many`
80
+ # head, otherwise one such Hash, or nil when that head matched no row
81
+ # @raise [ArgumentError] if `bluebook` is nil
82
+ # @raise [KeyError] if `args` has no entry for the model's reference argument
83
+ # @raise [Runtime::NotFound] if no projected row has the referenced root id
84
+ # @raise [SQLite3::Exception] if a statement fails
63
85
  def query_read_model(_domain, model, args, bluebook = nil)
64
86
  raise ArgumentError, "projection query needs its domain bluebook" unless bluebook
65
87
 
66
- # The REFERENCE's own shape, read the same way ReadModelInterpreter
88
+ # The reference's own shape, read the same way ReadModelInterpreter
67
89
  # reads it — not the identity unwrap, which is gone : an identity is
68
90
  # declared as a path and followed.
69
91
  reference_id = args.fetch(model.reference_name).to_s
@@ -72,7 +94,7 @@ module Hecks
72
94
  # eligible in the same read model now.
73
95
  eligible = model.filtered_head_names
74
96
 
75
- # ROOT FIRST, ALWAYS — see this method's own header. Mirrors
97
+ # **Root first, always** — see this method's own header. Mirrors
76
98
  # `ReadModelInterpreter#project`'s identical partition, for the
77
99
  # identical reason: a later head's own join has to be able to
78
100
  # match against a root (or another head) already resolved.
@@ -84,9 +106,9 @@ module Hecks
84
106
  rows = if head[:aggregate] == model.reference_target
85
107
  [select_projected(aggregate, reference_id) ||
86
108
  raise(Runtime::NotFound,
87
- Runtime::RefusalWording.render("NotFound", "read_model_reference_missing",
88
- aggregate: head[:aggregate],
89
- offered: Hecks::Rendering.describe(reference_id)))]
109
+ Runtime::RefusalWording.render_site("NotFound", "read_model_reference_missing",
110
+ aggregate: head[:aggregate],
111
+ offered: Hecks::Rendering.describe(reference_id)))]
90
112
  else
91
113
  select_related(aggregate, projected)
92
114
  end
@@ -108,12 +130,12 @@ module Hecks
108
130
  row && projected_instance(aggregate, row)
109
131
  end
110
132
 
111
- # Matched against EVERY source already projected (root first, then
133
+ # Matched against every source already projected (root first, then
112
134
  # declared order — see this class's own `query_read_model` header),
113
135
  # not only the root — a head whose own reference points at another
114
- # included head rather than the root directly used to match nothing
115
- # at all, since its reference attribute was compared against a
116
- # target (the root) it never names.
136
+ # included head rather than the root directly would otherwise match
137
+ # nothing at all, since its reference attribute would be compared
138
+ # against a target (the root) it never names.
117
139
  def select_related(aggregate, projected)
118
140
  matches = projected.flat_map do |source|
119
141
  references = aggregate.attributes.select do |attribute|
@@ -128,7 +150,7 @@ module Hecks
128
150
  end
129
151
  return [] if matches.empty?
130
152
 
131
- # A REFERENCE COLUMN HOLDS THE ID, so it compares as itself. The
153
+ # A reference column holds the ID, so it compares as itself. The
132
154
  # `json_extract(col,'$.value') = ? OR col = ?` this replaced was
133
155
  # reading both shapes because both existed — one written by the
134
156
  # command path, one by older journals. There is one shape now.
@@ -154,7 +176,7 @@ module Hecks
154
176
  if (lifecycle = aggregate.lifecycle) && fields.none? { |name, _| name == lifecycle.field }
155
177
  fields << [lifecycle.field, nil]
156
178
  end
157
- # `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
179
+ # `projects` fields (S12, ADR 0025) need reading back too — `project`
158
180
  # (above) already writes one into its own column via `persisted_fields`
159
181
  # (`Codec#persisted_fields`, this class's own superclass module), but
160
182
  # this method built its own independent field list that never
@@ -167,24 +189,30 @@ module Hecks
167
189
  fields
168
190
  end
169
191
 
192
+ # Decoded through the state codec (PR A3) against the row's own
193
+ # aggregate — see Codec#decode, including why a NULL projected-only
194
+ # column reads back absent.
170
195
  def decode_fields(fields, aggregate, row)
171
- fields.each_with_object({}) do |(name, attribute), state|
196
+ state = fields.each_with_object({}) do |(name, attribute), raw_state|
172
197
  raw = row[name.to_s]
173
198
  # rubocop:disable Lint/DuplicateBranch -- the nil-attribute and
174
199
  # reference-id branches both just answer `raw`, coincidentally, for
175
200
  # two unrelated reasons (see each branch's own comment); merging
176
201
  # them would blur that distinction.
177
- state[name] =
202
+ next if attribute.nil? && raw.nil? && aggregate.lifecycle&.field&.to_sym != name.to_sym
203
+
204
+ raw_state[name] =
178
205
  if attribute.nil?
179
206
  raw
180
207
  elsif attribute.list? || !aggregate.value_object(attribute.type).nil?
181
- raw ? JSON.parse(raw, symbolize_names: true) : nil
208
+ raw ? JSON.parse(raw) : nil
182
209
  else
183
210
  # A reference is a scalar id — see Codec#decode.
184
211
  raw
185
212
  end
186
213
  # rubocop:enable Lint/DuplicateBranch
187
214
  end
215
+ Ports::Persistence::StateCodec.decode(aggregate, state)
188
216
  end
189
217
  end
190
218
  end
@@ -12,11 +12,11 @@ module Hecks
12
12
  @db.execute(
13
13
  "CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{', ' unless columns.empty?}#{columns.join(', ')})"
14
14
  )
15
- # RIGHT HERE, NOT AS A SEPARATE STEP IN `Sqlite#initialize` —
15
+ # Right here, not as a separate step in `Sqlite#initialize` —
16
16
  # `create_aggregate_table!` is the one piece of DDL `D1`
17
17
  # already calls verbatim through this same shared module
18
18
  # (`d1.rb`'s own header: "reuses Sqlite::SchemaBuilder and
19
- # Sqlite::Codec UNCHANGED"). Folding index creation in here,
19
+ # Sqlite::Codec unchanged"). Folding index creation in here,
20
20
  # right after the table it indexes exists, is what makes D1's
21
21
  # own automatic indexing a real, running thing rather than an
22
22
  # aspiration that only Sqlite ever calls — no D1-specific
@@ -63,7 +63,7 @@ module Hecks
63
63
  @db.execute("ALTER TABLE #{quoted_entry_table} ADD COLUMN mirrors TEXT")
64
64
  end
65
65
 
66
- # THE OPTIONAL saga-persistence capability's own table (§2/§4) —
66
+ # The optional saga-persistence capability's own table (§2/§4) —
67
67
  # shared here so `D1`, which `include`s this module verbatim for
68
68
  # its own `events`-table DDL (`d1.rb`), gets this for free too.
69
69
  # `domain` stays an explicit column even though SQLite has no
@@ -131,10 +131,10 @@ module Hecks
131
131
  # `ensure_head_snapshot!`/`ensure_first_head!` already use) ────
132
132
 
133
133
  # Every field a declared query ever filters or sorts on, across
134
- # the aggregate's OWN queries and every entity's own queries —
134
+ # the aggregate's own queries and every entity's own queries —
135
135
  # `query_surfaces` in `dsl/aggregate_builder.rb` walks the
136
- # identical pair. AN ENTITY'S QUERY STILL RUNS AGAINST THIS
137
- # TABLE: it compiles through `query_expression`, which is
136
+ # identical pair. An entity's query still runs against this
137
+ # table: it compiles through `query_expression`, which is
138
138
  # `@aggregate`-scoped, not entity-scoped, so an entity's
139
139
  # declared `where`/`order_by` is indexed here too, same as any
140
140
  # other declared query — not skipped as "some other table's
@@ -158,14 +158,14 @@ module Hecks
158
158
  # plain-vs-nested decision);
159
159
  # - a non-list value-object attribute, referenced bare or
160
160
  # through a member path (`field` or `field.member`) — an
161
- # EXPRESSION index over `query_expression(field)`'s own
161
+ # expression index over `query_expression(field)`'s own
162
162
  # `json_extract(...)` text. Reusing the query compiler's own
163
163
  # expression, not re-deriving the string a second way, is
164
164
  # the whole point: a textual mismatch between the index and
165
165
  # what a real query compiles to is invisible to SQLite's
166
166
  # planner, and two independent copies of this logic can only
167
167
  # drift apart over time;
168
- # - a list-typed attribute — NO index. SQLite's `contains`
168
+ # - a list-typed attribute — no index. SQLite's `contains`
169
169
  # compiles to `EXISTS (SELECT 1 FROM json_each(col) WHERE
170
170
  # ...)` (`list_contains_clause`, in `sql_query_builder.rb`)
171
171
  # — an element-membership scan a plain index on the raw
@@ -178,8 +178,8 @@ module Hecks
178
178
  #
179
179
  # A field naming neither a real attribute nor the lifecycle
180
180
  # field can't happen through the DSL today — `seal_query_field`
181
- # (`dsl/aggregate_builder.rb`) already refuses it at PARSE time.
182
- # This runs at adapter BOOT, not parse time, so it skips rather
181
+ # (`dsl/aggregate_builder.rb`) already refuses it at parse time.
182
+ # This runs at adapter boot, not parse time, so it skips rather
183
183
  # than crashes ugly if that invariant is ever violated.
184
184
  def ensure_index_for_field!(field)
185
185
  name, * = field.to_s.split(".")
@@ -195,8 +195,8 @@ module Hecks
195
195
  )
196
196
  end
197
197
 
198
- # `idx_<table>_<sanitized field>` — TABLE-PREFIXED because
199
- # SQLite index names are GLOBAL to the database, not scoped per
198
+ # `idx_<table>_<sanitized field>` — table-prefixed because
199
+ # SQLite index names are global to the database, not scoped per
200
200
  # table the way a column name is; two different aggregates each
201
201
  # indexing a field called "name" would collide without it. The
202
202
  # field itself is sanitized (a dotted path's "." in particular)