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
@@ -2,56 +2,67 @@ module Hecks
2
2
  module Adapters
3
3
  class PostgresEra
4
4
  class Lineage
5
- # THE ONE CHUNKED, LOCK-FREE, RESUMABLE BACKFILL LOOP — shared by
6
- # `backfill_head_snapshot!` (era 1's existing one-shot blocking
7
- # backfill, retrofit) and every field-cache table's own initial
8
- # backfill (new). Governing principle 1 (docs/implemented/postgres-era-adapter-
9
- # split-plan.md): no operation this plan touches may hold a lock
10
- # across a scan whose duration scales with table size a single
11
- # `INSERT ... SELECT` over the whole journal (what
12
- # `backfill_head_snapshot!` used to be) is exactly that, and so is
13
- # a naive "populate every cache row in one statement" field-cache
14
- # backfill.
5
+ # The one chunked, lock-free, resumable backfill loop — shared by
6
+ # `backfill_head_snapshot!` (era 1's head-snapshot backfill) and
7
+ # every field-cache table's own initial backfill. Governing
8
+ # principle 1
9
+ # (docs/implemented/postgres-era-adapter-split-plan.md): no
10
+ # operation this plan touches may hold a lock across a scan whose
11
+ # duration scales with table size — a single `INSERT ... SELECT`
12
+ # over the whole journal (the one-shot form of
13
+ # `backfill_head_snapshot!`) is exactly that, and so is a naive
14
+ # "populate every cache row in one statement" field-cache backfill.
15
15
  #
16
- # THE SHAPE: read one bounded chunk (real rows, real ordinals) with
16
+ # ## The shape
17
+ #
18
+ # Read one bounded chunk (real rows, real ordinals) with
17
19
  # a plain SELECT — no lock held across it, so an ordinary reader or
18
20
  # writer is never blocked by a backfill in progress — then upsert
19
- # that chunk under the SAME transactionally-scoped advisory lock +
21
+ # that chunk under the same transactionally-scoped advisory lock +
20
22
  # ordinal-guard idiom `append`'s own snapshot upsert already uses
21
23
  # (`WHERE ordinal < EXCLUDED.ordinal`), then persist a cursor
22
24
  # before moving to the next chunk. Repeat until a chunk reads back
23
25
  # short of a full page — that page was the last one.
24
26
  #
25
- # RESUMABLE, not merely restartable. A crash (or a second
27
+ # ## Resumable, not merely restartable
28
+ #
29
+ # A crash (or a second
26
30
  # concurrent boot) mid-backfill leaves the cursor exactly where the
27
- # last COMMITTED chunk left it — `hecks_backfill_progress` is
28
- # updated in the SAME transaction as the chunk's own upsert, so
31
+ # last committed chunk left it — `hecks_backfill_progress` is
32
+ # updated in the same transaction as the chunk's own upsert, so
29
33
  # cursor and data can never observably disagree (see
30
34
  # `run_chunk!`). The next attempt reads that cursor and continues;
31
35
  # it does not rescan what a prior attempt already committed.
32
- # RESTARTABLE would also be CORRECT here (every upsert is
36
+ # Restartable would also be correct here (every upsert is
33
37
  # idempotent and ordinal-guarded — rerunning an already-done chunk
34
38
  # from id 1 changes nothing) but wastes real work on a large
35
- # table; resumability is what keeps a crash near the END of a
39
+ # table; resumability is what keeps a crash near the end of a
36
40
  # large backfill cheap to recover from instead of starting over.
37
41
  #
38
- # THE LOCK KEY PREFIX is `hecks_field_cache:` — deliberately
42
+ # ## The lock key
43
+ #
44
+ # The lock key prefix is `hecks_field_cache:` — deliberately
39
45
  # disjoint from the three families already in use elsewhere in
40
46
  # this adapter (`hecks_ordinal:`, `hecks_eras:`,
41
47
  # `hecks_head_snapshot:` — see lineage.rb/head_compiler.rb/
42
- # mint_transaction.rb/tail_merge.rb) so a backfill chunk NEVER
48
+ # mint_transaction.rb/tail_merge.rb) so a backfill chunk never
43
49
  # contends with a plain write, a mint, or a snapshot-table's own
44
- # first-creation lock. It is held for exactly ONE CHUNK's own
50
+ # first-creation lock. It is held for exactly one chunk's own
45
51
  # transaction, never across the whole backfill — two concurrent
46
- # backfillers of the SAME target simply take turns one chunk at a
52
+ # backfillers of the same target simply take turns one chunk at a
47
53
  # time rather than racing to duplicate work; neither blocks an
48
54
  # unrelated reader or writer for even an instant.
49
55
  module ResumableBackfill
50
56
  CHUNK_SIZE = 5_000
51
57
 
58
+ # Creates `hecks_backfill_progress`, the table of per-target backfill cursors, if absent.
59
+ #
52
60
  # Idempotent, unguarded — same idiom as every other DDL helper
53
61
  # in this file tree (`ensure_head_snapshot!` et al.): cheap,
54
62
  # runs on every boot, only ever does real work once.
63
+ #
64
+ # @return [void]
65
+ # @raise [PG::Error] if Postgres refuses the DDL
55
66
  def ensure_backfill_progress_table!
56
67
  @db.exec(<<~SQL)
57
68
  CREATE TABLE IF NOT EXISTS hecks_backfill_progress (
@@ -63,13 +74,15 @@ module Hecks
63
74
  SQL
64
75
  end
65
76
 
77
+ # Fills `target` one committed chunk at a time, resuming from its stored cursor.
78
+ #
66
79
  # Drives `target` (an already-created, currently-empty-or-
67
80
  # partially-filled table) through chunks until a source read
68
81
  # comes back short of `CHUNK_SIZE` rows. Two distinct callables,
69
82
  # not one — a head-snapshot row and a field-cache row carry
70
83
  # different columns (`state` jsonb vs. a single extracted
71
84
  # `value`), so there is no one generic "upsert this row" shape
72
- # to share; only the LOOP, the lock, and the cursor are generic.
85
+ # to share; only the loop, the lock, and the cursor are generic.
73
86
  #
74
87
  # source_sql.call(cursor) — given the last-processed id (nil
75
88
  # before the first chunk), returns a SQL SELECT whose result
@@ -82,11 +95,21 @@ module Hecks
82
95
  #
83
96
  # upsert.call(rows) — given the PG::Result of one chunk's
84
97
  # read, performs the actual guarded upsert into `target` and
85
- # returns nothing meaningful; runs INSIDE the same
98
+ # returns nothing meaningful; runs inside the same
86
99
  # transaction/advisory-lock scope as the cursor update below,
87
100
  # so a crash between "wrote the chunk" and "advanced the
88
101
  # cursor" is impossible — they commit together or not at
89
102
  # all.
103
+ #
104
+ # @param target [String] unquoted name of the table to fill; also the progress row's key
105
+ # and the advisory-lock key
106
+ # @param source_sql [#call] callable given the cursor (`String` id of the last row
107
+ # processed, nil before the first chunk) that returns the chunk's SELECT as a `String`
108
+ # @param upsert [#call] callable given one chunk's `PG::Result` that writes it into
109
+ # `target`; its return value is ignored
110
+ # @return [void]
111
+ # @raise [PG::Error] if Postgres refuses the progress-table DDL, a source read, or a
112
+ # cursor update; whatever `upsert` raises propagates too, rolling back that chunk
90
113
  def chunked_backfill!(target, source_sql:, upsert:)
91
114
  ensure_backfill_progress_table!
92
115
  loop do
@@ -97,8 +120,8 @@ module Hecks
97
120
 
98
121
  private
99
122
 
100
- # ONE CHUNK, ONE TRANSACTION, ONE SHORT-HELD LOCK. Re-reads
101
- # progress AFTER acquiring the lock (not just before) — a second
123
+ # One chunk, one transaction, one short-held lock. Re-reads
124
+ # progress after acquiring the lock (not just before) — a second
102
125
  # concurrent booter may have already finished this exact chunk
103
126
  # (or the whole backfill) while this process was waiting for the
104
127
  # lock; without the re-read, it would redundantly reprocess a
@@ -128,7 +151,7 @@ module Hecks
128
151
 
129
152
  upsert.call(rows)
130
153
  completed = rows.ntuples < CHUNK_SIZE
131
- # `PG::Result#[]` supports neither an out-of-range index NOR
154
+ # `PG::Result#[]` supports neither an out-of-range index nor
132
155
  # a negative one (unlike a plain Ruby Array) — same trap as
133
156
  # `backfill_progress` above, the explicit last-index form.
134
157
  last_cursor = rows[rows.ntuples - 1]["id"]
@@ -137,9 +160,9 @@ module Hecks
137
160
  completed
138
161
  end
139
162
 
140
- # `PG::Result#[]` RAISES IndexError on an out-of-range index —
163
+ # `PG::Result#[]` raises IndexError on an out-of-range index —
141
164
  # unlike a plain Ruby Array, it does not return nil — so the
142
- # ENTIRELY ORDINARY case of "no progress row exists yet" (every
165
+ # entirely ordinary case of "no progress row exists yet" (every
143
166
  # target's very first check) cannot be read via a bare `[0]`.
144
167
  # `ntuples.zero?` first, always.
145
168
  def backfill_progress(target)
@@ -11,8 +11,15 @@ module Hecks
11
11
  module TailMerge
12
12
  # ── fork observability ─────────────────────────────────────────
13
13
 
14
+ # Counts the journal rows an old era wrote after the next era's cut.
15
+ #
14
16
  # Post-cut writes an old era made after the newer era was minted
15
17
  # — the divergence between the worlds, observable at any time.
18
+ #
19
+ # @param old_era [Integer] ordinal of the superseded era to measure
20
+ # @return [Integer] number of `old_era` rows past the watermark of era `old_era + 1`;
21
+ # 0 when the domain holds no such newer era
22
+ # @raise [Runtime::WiringError] if a held era's text fails its integrity check
16
23
  def diverged_count(old_era)
17
24
  cut = eras.find { |era| era[:ordinal] == old_era + 1 }&.dig(:watermark)
18
25
  return 0 unless cut
@@ -24,6 +31,8 @@ module Hecks
24
31
 
25
32
  # ── tail-merge ─────────────────────────────────────────────────
26
33
  #
34
+ # Folds every old era's post-cut writes into the current era's heads, under named winners.
35
+ #
27
36
  # The one deliberate command — it marks a business event (an app
28
37
  # retiring), never a shape change. One transaction: advance the
29
38
  # watermarks, rebuild the head so the tail interleaves by its
@@ -34,13 +43,27 @@ module Hecks
34
43
  # re-enters as the newest row and wins structurally — originals
35
44
  # stay immutable).
36
45
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
37
- # One Postgres transaction (BEGINCOMMIT) with a manual ROLLBACK
46
+ # One Postgres transaction (`BEGIN`…`COMMIT`) with a manual `ROLLBACK`
38
47
  # at every early refusal, and a snapshot-before-mutation
39
48
  # invariant (`new_states` captured before the head rebuild lets
40
49
  # the tail interleave). Splitting the steps into separate
41
50
  # methods would force each to independently know how to roll
42
51
  # back the same shared transaction, and would separate the
43
52
  # snapshot from the mutation it must precede.
53
+ #
54
+ # @param aggregates [Array<Bluebook::Aggregate>] the current bluebook's aggregates, each
55
+ # of which gets its head rebuilt
56
+ # @param edges [Array<Hash{Symbol => Bluebook::Translation}>] the full edge chain in mint
57
+ # order, one `{ translation: }` Hash per step, as `LineageManager.edge_chain` builds it
58
+ # @param winners [Hash{String => String}] aggregate id to winning side, `"old"` or
59
+ # `"new"`; the winner's whole state is appended as the newest row
60
+ # @param audit [#call, nil] callable run just before `COMMIT` that returns an
61
+ # `Array<String>` of violations, empty to pass; nil skips the audit
62
+ # @return [true] always, once the merge is committed; every refusal raises instead
63
+ # @raise [Runtime::WiringError] if the domain stands at era 1, if an id touched by both
64
+ # worlds since the cut has no entry in `winners`, if `audit` reports a violation, if
65
+ # another mint or merge holds the domain lock for over 10s, if Postgres refuses any
66
+ # statement, or if a held era's text fails its integrity check
44
67
  def merge_tail!(aggregates:, edges:, winners: {}, audit: nil)
45
68
  @db.exec("BEGIN")
46
69
  @db.exec("SET LOCAL lock_timeout = '10s'")
@@ -159,18 +182,18 @@ module Hecks
159
182
  end
160
183
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
161
184
 
162
- # Ids touched by BOTH worlds since the cut — the old world's
185
+ # Ids touched by both worlds since the cut — the old world's
163
186
  # post-cut tail INTERSECTed with the new world's own writes.
164
187
  #
165
- # KNOWN GAP, not silently risked: this compares raw
188
+ # Known gap, not silently risked: this compares raw
166
189
  # `aggregate_id` values, with no notion of "these two different
167
190
  # ids are the same entity, rekeyed." If a domain's history
168
- # includes a rekey (see TranslationRekey) and is LATER
191
+ # includes a rekey (see TranslationRekey) and is later
169
192
  # merged here, a record's pre-rekey and post-rekey rows will
170
193
  # never intersect — they just silently survive as two separate,
171
194
  # unrelated-looking heads (a duplicate, not corruption: nothing
172
195
  # here deletes or clobbers either side). Resolve any such
173
- # duplicate manually after a merge; teaching this INTERSECT
196
+ # duplicate manually after a merge; teaching this intersect
174
197
  # about a rekey mapping is real, separate work, deliberately
175
198
  # out of scope for rekey's first pass.
176
199
  def conflict_ids(aggregate, edges, era, cut)
@@ -9,16 +9,18 @@ module Hecks
9
9
  # the cross-execution equivalence spec, never a second source of
10
10
  # truth.
11
11
  module TransformInstaller
12
+ # Installs or replaces the six `hecks_tr_*` functions under a database-wide advisory lock.
13
+ #
12
14
  # The jsonb rule transforms — installed once, idempotently. Kept
13
15
  # equal to the port's reference entry-JSON transform by the
14
16
  # cross-execution equivalence spec; the SQL here is a compilation
15
17
  # target, not a second source of truth.
16
18
  #
17
- # LOCKED, unlike every other statement `ensure_base!` runs — those
19
+ # Locked, unlike every other statement `ensure_base!` runs — those
18
20
  # are all `CREATE ... IF NOT EXISTS`/`ADD COLUMN IF NOT EXISTS`,
19
21
  # which Postgres itself resolves safely under concurrent boots.
20
22
  # `CREATE OR REPLACE FUNCTION` is not: it always rewrites the
21
- # `pg_proc` row, so two sessions racing to (re)install the SAME
23
+ # `pg_proc` row, so two sessions racing to (re)install the same
22
24
  # function — these six are shared/global, not per-domain, so any
23
25
  # two domains' concurrent first-boots can collide here — hit a
24
26
  # real `PG::InternalError: tuple concurrently updated`, not a
@@ -26,6 +28,9 @@ module Hecks
26
28
  # already-open-transaction-safe wrapper `ensure_field_cache!`
27
29
  # uses for its own advisory lock; a fixed, domain-independent key
28
30
  # is correct since these functions have no domain of their own.
31
+ #
32
+ # @return [void]
33
+ # @raise [PG::Error] if Postgres refuses the lock or a function definition
29
34
  def install_transforms!
30
35
  nested_transaction("hecks_tr_functions") do
31
36
  @db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_tr_functions'))", [])
@@ -33,14 +38,21 @@ module Hecks
33
38
  end
34
39
  end
35
40
 
36
- # Six independent CREATE OR REPLACE FUNCTION statements each
41
+ # Runs the six function definitions with no lock of its own; `install_transforms!` is
42
+ # the locked entry point.
43
+ #
44
+ # Six independent `CREATE OR REPLACE FUNCTION` statements — each
37
45
  # self-contained SQL, no shared Ruby state, and (per this
38
46
  # module's own header comment) safe in any install order since
39
47
  # plpgsql bodies aren't resolved against each other until
40
- # called, not at CREATE time. Split one-per-function below
48
+ # called, not at create time. Split one-per-function below
41
49
  # purely so each has its own name and (where relevant) its own
42
50
  # comment to sit next to, not because the six have any
43
51
  # sequencing dependency on one another.
52
+ #
53
+ # @return [void]
54
+ # @raise [PG::Error] if Postgres refuses a definition, including
55
+ # `tuple concurrently updated` when two unlocked sessions race
44
56
  def install_transform_functions!
45
57
  install_hecks_tr_extract!
46
58
  install_hecks_tr_insert!
@@ -84,15 +96,16 @@ module Hecks
84
96
  SQL
85
97
  end
86
98
 
87
- # ADVERSARIAL FINDING: a destination whose top segment already
99
+ # **Adversarial finding**: a destination whose top segment already
88
100
  # holds a value — most commonly a reference, a bare scalar id
89
- # — used to be silently overwritten with an empty object the
90
- # moment a dotted destination needed to nest under it. That is
91
- # a drop that never declared itself, the one thing this
92
- # language exists to make explicit (see hecks_tr_convert's own
93
- # refusal below, the same shape) — refused here instead, with
94
- # the Ruby reference transform (ports/persistence/lineage.rb's
95
- # `insert`) raising the identical wording.
101
+ # — is refused rather than silently overwritten with an empty
102
+ # object the moment a dotted destination needs to nest under it.
103
+ # Overwriting is a drop that never declared itself, the one
104
+ # thing this language exists to make explicit (see
105
+ # hecks_tr_convert's own refusal below, the same shape). The
106
+ # Ruby reference transform
107
+ # (ports/persistence/plugins/era/lineage.rb's `insert`) raises
108
+ # the identical wording.
96
109
  def install_hecks_tr_insert!
97
110
  @db.exec(<<~SQL)
98
111
  CREATE OR REPLACE FUNCTION hecks_tr_insert(state jsonb, path text[], value jsonb, rule_label text) RETURNS jsonb
@@ -15,65 +15,75 @@ module Hecks
15
15
  module Adapters
16
16
  class PostgresEra
17
17
  # The lineage topology inside one Postgres database: one journal
18
- # per domain, LIST-partitioned by era, with a single ordinal
18
+ # per domain, list-partitioned by era, with a single ordinal
19
19
  # sequence spanning partitions (total order across eras is
20
20
  # structural); a `hecks_eras` table holding each era's frozen
21
21
  # source text, its once-minted hash/label, and the watermark cut
22
- # into its ancestor; and, per aggregate, a HEAD derived from the
22
+ # into its ancestor; and, per aggregate, a head derived from the
23
23
  # journal — never a table anything rewrites.
24
24
  #
25
+ # ## How a head is derived
26
+ #
25
27
  # For era 1 the head is a plain view (latest save per id). From
26
28
  # era 2 on, the ancestor tail is a MATERIALIZED view whose
27
29
  # definition is the compiled, chained edge sequence — one CTE per
28
30
  # original edge, in mint order, never a flattened merged rule set
29
31
  # — with the watermark baked into the definition, so post-cut
30
- # old-era writes cannot leak into the new head even on REFRESH.
32
+ # old-era writes cannot leak into the new head even on `REFRESH`.
31
33
  # Live current-era writes overlay it through the head view.
32
34
  #
35
+ # ## The current-era write fence
36
+ #
33
37
  # Writing to a superseded schema drops the instant the new one
34
- # materializes: ONE shared row policy admits INSERTs to whichever
38
+ # materializes: one shared row policy admits INSERTs to whichever
35
39
  # era was just established (era 1 at first hold, era N at mint),
36
- # and advancing it is part of the SAME transaction that builds the
40
+ # and advancing it is part of the same transaction that builds the
37
41
  # new era's matview. There is no persisted per-role fork — any
38
- # granted role, app or the table's own OWNER (FORCE ROW LEVEL
39
- # SECURITY applies this to the owner too, not only ordinary
40
- # roles), writes the current era or nothing, from the moment that
42
+ # granted role, app or the table's own owner
43
+ # (`FORCE ROW LEVEL SECURITY` applies this to the owner too, not only
44
+ # ordinary roles), writes the current era or nothing, from the moment that
41
45
  # transaction commits. A stale-era write during the narrow window
42
46
  # before that commit — RLS is checked once, when the statement
43
- # EXECUTES, never re-checked at commit, so a transaction that
47
+ # executes, never re-checked at commit, so a transaction that
44
48
  # inserted while the old era was still current can still land
45
49
  # after a concurrent mint has already moved the fence on — is
46
- # exactly what diverged_count/merge_tail exist to reconcile; it is
47
- # the residual of an unavoidable race (ordinals are
50
+ # exactly what `diverged_count`/`merge_tail!` exist to reconcile; it
51
+ # is the residual of an unavoidable race (ordinals are
48
52
  # sequence-assigned, not transactional — see below), not a
49
53
  # supported way to keep operating two schemas side by side. Only
50
54
  # an actual Postgres superuser (or a role granted BYPASSRLS)
51
- # sits above FORCE and keeps writing at will, forever.
55
+ # sits above `FORCE` and keeps writing at will, forever.
56
+ #
57
+ # ## Ordinal order and commit order
52
58
  #
53
59
  # Lineage order is ordinal-assignment order: the ordinal comes from a
54
60
  # sequence, and a sequence's `nextval()` is never rolled back with its
55
61
  # transaction — accepted and documented rather than papered over.
56
62
  #
57
- # ONE PART OF THAT IS CLOSED. Two concurrent PLAIN writes could call
58
- # `nextval()` in one order and COMMIT in the other — nothing about a
59
- # single autocommit INSERT statement stops a slower one from finishing
60
- # after a faster one that started later — so "ordinal order" and
61
- # "commit order" were formally two different total orders even with no
62
- # mint anywhere near either write. `PostgresEra#append` now holds
63
+ # **One part of that is closed**. Left unserialized, two concurrent
64
+ # plain writes can call `nextval()` in one order and COMMIT in the
65
+ # other — nothing about a single autocommit INSERT statement stops a
66
+ # slower one from finishing after a faster one that started later —
67
+ # which makes "ordinal order" and "commit order" formally two different
68
+ # total orders even with no mint anywhere near either write. So
69
+ # `PostgresEra#append` holds
63
70
  # `pg_advisory_xact_lock(hashtext('hecks_ordinal:' || domain))` for the
64
- # length of its own transaction, a DIFFERENT key from `mint_era!` and
65
- # `merge_tail!`'s `hecks_eras:domain` — so plain writes serialize
66
- # against EACH OTHER only, never against a mint, and ordinal order
67
- # equals commit order for them now.
71
+ # length of its own transaction, a different key from `mint_era!` and
72
+ # `merge_tail!`'s `hecks_eras:domain` — plain writes serialize against
73
+ # each other only, never against a mint, and ordinal order equals
74
+ # commit order for them.
68
75
  #
69
- # THE OTHER PART IS NOT, ON PURPOSE. A stale-era write during the
70
- # narrow window before a mint's fence-move commits is the SAME race by
76
+ # **The other part is deliberately open**. A stale-era write during the
77
+ # narrow window before a mint's fence-move commits is the same race by
71
78
  # a different name — and closing it would mean a plain write
72
79
  # serializing against a mint, which is exactly the guarantee
73
- # `postgres_lineage_spec.rb`'s "an old checkout keeps writing its own
74
- # era THROUGH a mint" pins the ABSENCE of. That race stays the
75
- # residual `diverged_count`/`merge_tail!` exist to reconcile, not
76
- # something a plain write should ever block for.
80
+ # `spec/adapters/driven/postgres_era/lineage_spec.rb`'s "an old
81
+ # checkout keeps writing its own era through a mint" pins the absence
82
+ # of. That race stays the residual `diverged_count`/`merge_tail!`
83
+ # exist to reconcile, not something a plain write should ever block
84
+ # for.
85
+ #
86
+ # ## File layout
77
87
  #
78
88
  # One concern per file under lineage/: provisioning (DDL and the
79
89
  # RLS posture), era_store (the hecks_eras rows and their integrity),
@@ -96,33 +106,118 @@ module Hecks
96
106
 
97
107
  JOURNAL_COLUMNS = "ordinal, era, aggregate, aggregate_id, operation, state, mirrors".freeze
98
108
 
109
+ # Postgres's own NAMEDATALEN limit: an identifier over 63 bytes is
110
+ # silently truncated, never refused — so two different overlong
111
+ # names that happen to share their first 63 bytes would collide
112
+ # again, at a longer length, the exact same failure mode this
113
+ # whole file exists to close for `storage_name` alone. Domain-
114
+ # qualifying every name below (`qualified_name`, private) makes
115
+ # that readily reachable: a long domain name stacked onto a long
116
+ # aggregate name. A constant, not private — Ruby constants are
117
+ # never actually scoped by `private`
118
+ # (Lint/UselessConstantScoping), so this stays above it.
119
+ POSTGRES_IDENTIFIER_LIMIT = 63
120
+
99
121
  attr_reader :db, :domain, :formerly_known_as
100
122
 
123
+ # @param db [PG::Connection] open connection to the database holding the domain's journal
124
+ # @param domain [String, Symbol] the domain's declared name; every relation name and
125
+ # advisory-lock key derives from it
126
+ # @param formerly_known_as [String, Symbol, nil] the domain's prior name, which makes
127
+ # `ensure_base!` rename its relations and rows first; nil when there is no prior name
101
128
  def initialize(db, domain, formerly_known_as: nil)
102
129
  @db = db
103
130
  @domain = domain.to_s
104
131
  @formerly_known_as = formerly_known_as&.to_s
105
132
  end
106
133
 
134
+ # Names the domain's one journal table, the partitioned parent of every era's rows.
135
+ #
136
+ # @return [String] unquoted relation name, `hecks_journal_` plus the snake-cased domain
107
137
  def journal = "hecks_journal_#{Naming.snake(@domain)}"
138
+
139
+ # Quotes the journal's name for direct interpolation into SQL.
140
+ #
141
+ # @return [String] `journal` as a double-quoted Postgres identifier
108
142
  def quoted_journal = quote(journal)
143
+
144
+ # Names the sequence that assigns journal ordinals across every era's partition.
145
+ #
146
+ # @return [String] unquoted sequence name, `journal` plus `_ordinal`
109
147
  def sequence = "#{journal}_ordinal"
148
+
149
+ # Names the journal partition that holds one era's rows.
150
+ #
151
+ # @param era [Integer] the era's ordinal, 1-based
152
+ # @return [String] unquoted relation name, `journal` plus `_era_` and the ordinal
110
153
  def partition(era) = "#{journal}_era_#{era}"
111
- def head_view(storage_name) = "#{storage_name}_head"
154
+
155
+ # Names the view an aggregate's current state is read through.
156
+ #
157
+ # Domain-qualified, the same way `journal` is — see
158
+ # `qualified_name`'s own comment and docs/decisions/0059. Unqualified,
159
+ # two different domains bound to PostgresEra against the same
160
+ # database, each declaring an aggregate whose own name snake_cases
161
+ # to the same storage_name (found live: two unrelated "Note"
162
+ # aggregates), derive the exact same
163
+ # `note_head`/`note_head_snapshot_1` physical relations — every
164
+ # boot of the second domain silently clobbers the first's
165
+ # already-compiled head view, `ensure_first_head!`'s own
166
+ # "belt-and-suspenders self-healing" being exactly the mechanism
167
+ # that does it (postgres_era.rb's own comment there).
168
+ #
169
+ # @param storage_name [String] the aggregate's snake-cased storage name
170
+ # @return [String] unquoted view name, at most `POSTGRES_IDENTIFIER_LIMIT` bytes
171
+ def head_view(storage_name) = qualified_name("#{storage_name}_head")
172
+
173
+ # Names the snapshot table that backs one aggregate's head within one era.
174
+ #
112
175
  # The transactionally-upserted read cache behind head_view — one row
113
- # per LIVE id, keyed by id, carrying the ordinal it was last written
114
- # at. Scoped by ERA, not just storage_name — an aggregate that
115
- # ISN'T renamed across a mint keeps the SAME storage_name in both
176
+ # per live id, keyed by id, carrying the ordinal it was last written
177
+ # at. Scoped by era, not just storage_name — an aggregate that
178
+ # isn't renamed across a mint keeps the same storage_name in both
116
179
  # eras, so storage_name alone would have era N+1 sharing one
117
180
  # physical table with era N: a freshly-minted era would inherit
118
181
  # every pre-mint (and, worse, pre-rekey/pre-translation) row
119
182
  # instead of starting empty. era-qualified naming is what
120
183
  # `partition`/`matview` already do for exactly this reason.
121
- def head_snapshot(storage_name, era) = "#{storage_name}_head_snapshot_#{era}"
122
- def matview(storage_name, era, label) = "#{storage_name}_lineage_#{era}_#{label}"
184
+ #
185
+ # @param storage_name [String] the aggregate's snake-cased storage name
186
+ # @param era [Integer] ordinal of the era the snapshot belongs to
187
+ # @return [String] unquoted table name, at most `POSTGRES_IDENTIFIER_LIMIT` bytes
188
+ def head_snapshot(storage_name, era) = qualified_name("#{storage_name}_head_snapshot_#{era}")
189
+
190
+ # Names the materialized view holding an aggregate's translated ancestor tail for one era.
191
+ #
192
+ # @param storage_name [String] the aggregate's snake-cased storage name
193
+ # @param era [Integer] ordinal of the era the view was compiled for
194
+ # @param label [String] the era's minted label, a prefix of its shape hash
195
+ # @return [String] unquoted matview name, at most `POSTGRES_IDENTIFIER_LIMIT` bytes
196
+ def matview(storage_name, era, label) = qualified_name("#{storage_name}_lineage_#{era}_#{label}")
123
197
 
124
198
  private
125
199
 
200
+ # `@domain`, snake-cased and folded onto `suffix` — human-readable
201
+ # in the ordinary case (every one of these names gets read
202
+ # directly at a psql prompt during a live incident — see
203
+ # docs/decisions/0059's own verification section), degrading to a
204
+ # hashed, truncated form only once the readable form would
205
+ # actually risk exceeding `POSTGRES_IDENTIFIER_LIMIT`. The same
206
+ # trade `Runtime::StorageShape.mint_label` (a bare hash-prefix, no
207
+ # attempt at readability at all — a mint label is never meant to
208
+ # be legible on its own) and `FieldCache#field_cache` (fully
209
+ # hashed, for the same reason) already make elsewhere in this
210
+ # adapter — this one keeps more of the readable form than either,
211
+ # since unlike a mint label or a field-cache table, these names
212
+ # are the ones an operator reads and types by hand.
213
+ def qualified_name(suffix)
214
+ full = "#{Naming.snake(@domain)}_#{suffix}"
215
+ return full if full.bytesize <= POSTGRES_IDENTIFIER_LIMIT
216
+
217
+ digest = Digest::SHA256.hexdigest(full)[0, 8]
218
+ "#{full.byteslice(0, POSTGRES_IDENTIFIER_LIMIT - digest.bytesize - 1)}_#{digest}"
219
+ end
220
+
126
221
  def quote(name) = PG::Connection.quote_ident(name.to_s)
127
222
 
128
223
  def text_literal(text) = "'#{text.to_s.gsub("'", "''")}'"
@@ -10,12 +10,26 @@ module Hecks
10
10
  module LineageManager
11
11
  # What a mint must prove before it may happen: the edge covers
12
12
  # the whole diff, no identity path was re-keyed, and the audit's
13
- # first two layers pass over the LIVE compiled chain.
13
+ # first two layers pass over the live compiled chain.
14
14
  module CoverageCheck
15
- # Layer 1 against THIS edge specifically: every vanished or
15
+ # Refuses a mint whose translation edge leaves any part of the shape change
16
+ # unexplained.
17
+ #
18
+ # Layer 1 against this edge specifically: every vanished or
16
19
  # retyped path in the held→current diff must be explained, and
17
20
  # every held aggregate must still be claimed. The refusal is
18
21
  # EraGuard's own, byte for byte.
22
+ #
23
+ # @param registry [Runtime::Registry] the registry whose loaded translations may
24
+ # claim a held aggregate through `was:` or `retired`
25
+ # @param bluebook [Bluebook::Chapter] the domain as currently declared
26
+ # @param held_bluebook [Bluebook::Chapter] the domain as the latest held era's
27
+ # text declares it (a shadow parse)
28
+ # @param edge [Bluebook::Translation] the one edge leaving the latest held era
29
+ # @return [void]
30
+ # @raise [Runtime::WiringError] if an identity path changed without a `rekey`
31
+ # rule, a vanished or retyped path has no rule, a new required attribute has
32
+ # nothing to fill it, or a held aggregate is neither renamed nor retired
19
33
  def check_coverage!(registry, bluebook, held_bluebook, edge)
20
34
  bluebook.aggregates.each do |aggregate|
21
35
  rules = Ports::Persistence::Lineage.from_declared(edge.for_aggregate(aggregate.name), aggregate.name)
@@ -32,15 +46,28 @@ module Hecks
32
46
  Runtime::EraGuard.check_vanished_aggregates!(registry, bluebook, held_bluebook)
33
47
  end
34
48
 
35
- # An identity-path change is a RE-KEYING, not an ordinary
49
+ # Refuses a mint that changes an aggregate's identity paths without declaring
50
+ # a `rekey`.
51
+ #
52
+ # An identity-path change is a re-keying, not an ordinary
36
53
  # translation — stored ids were fixed at write time under the old
37
- # key, so this refuses UNLESS the edge declares a `rekey` for
54
+ # key, so this refuses unless the edge declares a `rekey` for
38
55
  # this aggregate covering exactly that. `rules.rekey?` (see
39
56
  # `Ports::Persistence::Lineage`'s own comment) is the single
40
57
  # source of truth every consumer of this fact asks — this is not
41
58
  # a second, independent check of `declared.rekeys`.
59
+ #
60
+ # @param bluebook [Bluebook::Chapter] the domain, named in the refusal
61
+ # @param aggregate [Bluebook::Aggregate] the aggregate as currently declared
62
+ # @param held_aggregate [Bluebook::Aggregate] the same aggregate as the held
63
+ # era's text declares it
64
+ # @param rules [Ports::Persistence::Lineage, nil] the edge's rules for this
65
+ # aggregate; nil when the edge declares none, which cannot excuse a change
66
+ # @return [nil] when the identity paths are unchanged or a `rekey` covers them
67
+ # @raise [Runtime::WiringError] if the identity paths differ and no `rekey` rule
68
+ # is declared
42
69
  def check_identity_unchanged!(bluebook, aggregate, held_aggregate, rules)
43
- # The FULL declared path lists, in declaration order — never the
70
+ # The full declared path lists, in declaration order — never the
44
71
  # single-head shortcut, which is nil for every composite identity
45
72
  # and so would let two different composites compare as unchanged.
46
73
  return if held_aggregate.identity_paths == aggregate.identity_paths
@@ -56,10 +83,28 @@ module Hecks
56
83
  "data explicitly"
57
84
  end
58
85
 
59
- # Layers 1 and 2 of the audit, over the LIVE compiled chain
86
+ # Previews every aggregate's translated head through the pending chain and
87
+ # refuses the mint if the audit finds a violation.
88
+ #
89
+ # Layers 1 and 2 of the audit, over the live compiled chain —
60
90
  # before anything is minted, so a refusal leaves no half-born
61
91
  # era. (A convert meeting an unmapped value raises inside the
62
92
  # preview query itself: same rollback-shaped outcome.)
93
+ #
94
+ # @param bluebook [Bluebook::Chapter] the domain as currently declared
95
+ # @param lineage [Adapters::PostgresEra::Lineage] the domain's lineage, on an open
96
+ # connection
97
+ # @param chain [Array<Hash{Symbol => Bluebook::Translation}>] the full edge chain
98
+ # ending in `edge`, as `edge_chain` returns it
99
+ # @param ordinal [Integer] the ordinal of the era about to be minted
100
+ # @param edge [Bluebook::Translation] the pending edge, whose per-aggregate rules
101
+ # the audit checks
102
+ # @return [nil] when no aggregate reports a violation
103
+ # @raise [Runtime::WiringError] if Postgres refuses the translated preview query
104
+ # (a `convert` meeting an unmapped value, for one), or the audit reports any
105
+ # violation; the message lists them all
106
+ # @raise [PG::Error] if Postgres refuses the "before" query; only the translated
107
+ # preview is rescued into a `Runtime::WiringError`
63
108
  def audit!(bluebook, lineage, chain, ordinal, edge)
64
109
  violations = []
65
110
  bluebook.aggregates.each do |aggregate|