hecks 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +7 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +10 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +8 -8
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +31 -13
  7. data/lib/hecks/adapters/driven/heki/journal.rb +60 -2
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +5 -5
  9. data/lib/hecks/adapters/driven/heki.rb +13 -7
  10. data/lib/hecks/adapters/driven/identity_registry.rb +2 -2
  11. data/lib/hecks/adapters/driven/in_memory_ordering.rb +3 -3
  12. data/lib/hecks/adapters/driven/lambda/client.rb +34 -9
  13. data/lib/hecks/adapters/driven/lambda.rb +39 -33
  14. data/lib/hecks/adapters/driven/local_storage.rb +17 -10
  15. data/lib/hecks/adapters/driven/memory.rb +205 -9
  16. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +1 -1
  17. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  18. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  19. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  20. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  21. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  22. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  23. data/lib/hecks/adapters/driven/prism.rb +4 -4
  24. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  25. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  26. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  27. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  28. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  29. data/lib/hecks/adapters/driven.rb +4 -4
  30. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  31. data/lib/hecks/behaviors/dsl.rb +2 -2
  32. data/lib/hecks/behaviors/expectations.rb +51 -23
  33. data/lib/hecks/behaviors/ir.rb +1 -1
  34. data/lib/hecks/behaviors/rspec.rb +1 -1
  35. data/lib/hecks/behaviors/runner.rb +2 -2
  36. data/lib/hecks/behaviors.rb +1 -1
  37. data/lib/hecks/bluebook/aggregate.rb +13 -13
  38. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
  39. data/lib/hecks/bluebook/assembly/build.rb +1 -1
  40. data/lib/hecks/bluebook/assembly/contract.rb +39 -16
  41. data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
  42. data/lib/hecks/bluebook/assembly/marks.rb +18 -18
  43. data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
  44. data/lib/hecks/bluebook/assembly.rb +9 -9
  45. data/lib/hecks/bluebook/attribute.rb +9 -9
  46. data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
  47. data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
  48. data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
  49. data/lib/hecks/bluebook/behaviour/command.rb +23 -23
  50. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -3
  51. data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
  52. data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
  53. data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
  54. data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
  55. data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
  56. data/lib/hecks/bluebook/behaviour/query.rb +1 -1
  57. data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
  58. data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
  59. data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
  60. data/lib/hecks/bluebook/capabilities.rb +27 -0
  61. data/lib/hecks/bluebook/chapter.rb +28 -9
  62. data/lib/hecks/bluebook/command.rb +12 -12
  63. data/lib/hecks/bluebook/domain_port.rb +9 -9
  64. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  65. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  66. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
  67. data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
  68. data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
  69. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
  70. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
  71. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  72. data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
  73. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  74. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  75. data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
  76. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  77. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
  78. data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
  79. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  80. data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
  81. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  82. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  83. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
  84. data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
  85. data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
  86. data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
  87. data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
  88. data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
  89. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  90. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  91. data/lib/hecks/bluebook/entity.rb +11 -11
  92. data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
  93. data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
  94. data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
  95. data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
  96. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
  97. data/lib/hecks/bluebook/expression/resolver.rb +60 -62
  98. data/lib/hecks/bluebook/hexagon.rb +1 -1
  99. data/lib/hecks/bluebook/lifecycle.rb +1 -1
  100. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
  101. data/lib/hecks/bluebook/meta_validator/judge.rb +122 -86
  102. data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
  103. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
  104. data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
  105. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
  106. data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
  107. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
  108. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
  109. data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
  110. data/lib/hecks/bluebook/meta_validator.rb +70 -70
  111. data/lib/hecks/bluebook/model_check.rb +301 -84
  112. data/lib/hecks/bluebook/pattern_subset.rb +9 -9
  113. data/lib/hecks/bluebook/policy.rb +15 -13
  114. data/lib/hecks/bluebook/process_manager.rb +14 -14
  115. data/lib/hecks/bluebook/project_register.rb +6 -6
  116. data/lib/hecks/bluebook/query.rb +4 -4
  117. data/lib/hecks/bluebook/read_model.rb +14 -14
  118. data/lib/hecks/bluebook/reference.rb +8 -8
  119. data/lib/hecks/bluebook/smoke_test.rb +19 -19
  120. data/lib/hecks/bluebook/synthesizer.rb +12 -12
  121. data/lib/hecks/bluebook/translation.rb +4 -4
  122. data/lib/hecks/bluebook/value_object.rb +6 -6
  123. data/lib/hecks/bluebook.rb +2 -2
  124. data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
  125. data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
  126. data/lib/hecks/codemod.rb +36 -35
  127. data/lib/hecks/construct.rb +6 -6
  128. data/lib/hecks/corpus.rb +317 -0
  129. data/lib/hecks/deprecation.rb +95 -0
  130. data/lib/hecks/doc/reference.rb +19 -19
  131. data/lib/hecks/embryonaut_bluebook.rb +11 -11
  132. data/lib/hecks/facade/cli_door.rb +69 -10
  133. data/lib/hecks/facade/cli_runner.rb +105 -24
  134. data/lib/hecks/facade/command_request.rb +23 -0
  135. data/lib/hecks/facade/handle.rb +79 -32
  136. data/lib/hecks/facade/json_door.rb +106 -25
  137. data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
  138. data/lib/hecks/facade/surface/chapter.rb +26 -17
  139. data/lib/hecks/facade/surface.rb +16 -3
  140. data/lib/hecks/facade.rb +15 -4
  141. data/lib/hecks/forms/app.rb +46 -30
  142. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  143. data/lib/hecks/forms/field_renderer.rb +142 -6
  144. data/lib/hecks/forms/field_shape.rb +183 -20
  145. data/lib/hecks/forms/html.rb +51 -7
  146. data/lib/hecks/forms/index_renderer.rb +14 -2
  147. data/lib/hecks/forms/params.rb +120 -23
  148. data/lib/hecks/forms/port_argument.rb +2 -2
  149. data/lib/hecks/forms/query_form_renderer.rb +2 -2
  150. data/lib/hecks/forms/record_renderer.rb +2 -2
  151. data/lib/hecks/forms/record_table.rb +1 -1
  152. data/lib/hecks/forms/value_object_shape.rb +3 -3
  153. data/lib/hecks/forms.rb +24 -4
  154. data/lib/hecks/fqn.rb +1 -1
  155. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  156. data/lib/hecks/framework.rb +48 -17
  157. data/lib/hecks/freezer.rb +11 -11
  158. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
  159. data/lib/hecks/fuzzing/combination_miner.rb +119 -0
  160. data/lib/hecks/fuzzing/concurrent_dispatch.rb +361 -0
  161. data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
  162. data/lib/hecks/fuzzing/differential.rb +158 -0
  163. data/lib/hecks/fuzzing/domain_generator.rb +694 -0
  164. data/lib/hecks/fuzzing/era_boundary.rb +124 -0
  165. data/lib/hecks/fuzzing/form_census.rb +199 -0
  166. data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
  167. data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
  168. data/lib/hecks/fuzzing/isolated_boot.rb +226 -38
  169. data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
  170. data/lib/hecks/fuzzing/persistence_parity.rb +161 -0
  171. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  172. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +321 -41
  173. data/lib/hecks/fuzzing/properties/guards.rb +129 -26
  174. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
  175. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +31 -11
  176. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  177. data/lib/hecks/fuzzing/properties/querying.rb +17 -17
  178. data/lib/hecks/fuzzing/properties.rb +84 -39
  179. data/lib/hecks/fuzzing/qa_settings.rb +152 -0
  180. data/lib/hecks/fuzzing/replay.rb +224 -114
  181. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  182. data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
  183. data/lib/hecks/fuzzing/self_consistency.rb +676 -0
  184. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  185. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +101 -30
  186. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +55 -8
  187. data/lib/hecks/fuzzing/sequence_generator/picker.rb +31 -11
  188. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +123 -32
  189. data/lib/hecks/fuzzing/sequence_generator.rb +152 -31
  190. data/lib/hecks/fuzzing/shrinker.rb +197 -0
  191. data/lib/hecks/fuzzing/structural_skips.rb +39 -0
  192. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  193. data/lib/hecks/fuzzing/target_capabilities.rb +180 -0
  194. data/lib/hecks/fuzzing/value_generator.rb +66 -14
  195. data/lib/hecks/fuzzing.rb +11 -0
  196. data/lib/hecks/grammar/evolve.rb +10 -10
  197. data/lib/hecks/grammar.rb +7 -7
  198. data/lib/hecks/ir.rb +13 -13
  199. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  200. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  201. data/lib/hecks/language/bluebook/vocabulary.bluebook +365 -15
  202. data/lib/hecks/language/oidc.json +5 -0
  203. data/lib/hecks/literal.rb +9 -9
  204. data/lib/hecks/naming.rb +89 -21
  205. data/lib/hecks/ports/access_control.rb +58 -2
  206. data/lib/hecks/ports/agent/answers.rb +83 -6
  207. data/lib/hecks/ports/agent.rb +119 -35
  208. data/lib/hecks/ports/authentication.rb +44 -4
  209. data/lib/hecks/ports/authorization.rb +53 -11
  210. data/lib/hecks/ports/clock.rb +42 -23
  211. data/lib/hecks/ports/extraction.rb +16 -0
  212. data/lib/hecks/ports/identity_assignment.rb +24 -2
  213. data/lib/hecks/ports/identity_generation.rb +17 -3
  214. data/lib/hecks/ports/identity_resolution.rb +18 -1
  215. data/lib/hecks/ports/loading.rb +4 -0
  216. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  217. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  218. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  219. data/lib/hecks/ports/persistence/execution.rb +4 -0
  220. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  221. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  222. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  223. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  224. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  225. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  226. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  227. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  228. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  229. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  230. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  231. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +160 -58
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +44 -6
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +338 -83
  242. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  243. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  244. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  245. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
  246. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  247. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  248. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  249. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
  250. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
  251. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
  252. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  253. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  254. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  255. data/lib/hecks/ports/persistence/repository_factory.rb +29 -6
  256. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  257. data/lib/hecks/ports/persistence.rb +36 -1
  258. data/lib/hecks/ports/projection.rb +61 -7
  259. data/lib/hecks/ports/query/in_memory.rb +3 -3
  260. data/lib/hecks/ports/query/ordering.rb +6 -6
  261. data/lib/hecks/ports/query.rb +35 -0
  262. data/lib/hecks/projections/bootstrap_table.rb +112 -0
  263. data/lib/hecks/projections/diagrams.rb +75 -75
  264. data/lib/hecks/projections/glossary/html.rb +250 -0
  265. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  266. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  267. data/lib/hecks/projections/glossary/page.css +271 -0
  268. data/lib/hecks/projections/glossary/page.js +72 -0
  269. data/lib/hecks/projections/glossary/sections.rb +17 -0
  270. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  271. data/lib/hecks/projections/glossary.rb +214 -286
  272. data/lib/hecks/projections/ir.rb +1 -1
  273. data/lib/hecks/projections/model/deviations.rb +18 -17
  274. data/lib/hecks/projections/model.rb +25 -21
  275. data/lib/hecks/projections/oidc.rb +7 -7
  276. data/lib/hecks/projections/parser_table.rb +5 -5
  277. data/lib/hecks/projections/reference.rb +3 -3
  278. data/lib/hecks/projections/rust_vocabulary.rb +443 -0
  279. data/lib/hecks/projections/shape.rb +2 -2
  280. data/lib/hecks/projections/statements.rb +11 -11
  281. data/lib/hecks/projections/vocabulary.rb +9 -9
  282. data/lib/hecks/projections.rb +5 -3
  283. data/lib/hecks/projector/cli_projector.rb +29 -29
  284. data/lib/hecks/projector/docs_projector.rb +13 -13
  285. data/lib/hecks/projector/exporter.rb +42 -21
  286. data/lib/hecks/projector/ir_projector.rb +1 -1
  287. data/lib/hecks/projector/narrate_projector.rb +15 -22
  288. data/lib/hecks/projector/target.rb +13 -13
  289. data/lib/hecks/projector.rb +15 -15
  290. data/lib/hecks/query_ir.rb +47 -47
  291. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  292. data/lib/hecks/query_specification/common/comparison.rb +132 -24
  293. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  294. data/lib/hecks/query_specification/common/null_policy.rb +57 -13
  295. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  296. data/lib/hecks/query_specification/common/options.rb +25 -0
  297. data/lib/hecks/query_specification/field_path.rb +69 -15
  298. data/lib/hecks/query_specification/hop_path.rb +57 -20
  299. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  300. data/lib/hecks/rendering.rb +3 -3
  301. data/lib/hecks/router/namespace_installer.rb +3 -3
  302. data/lib/hecks/router.rb +1 -1
  303. data/lib/hecks/runtime/aggregate_lock.rb +11 -11
  304. data/lib/hecks/runtime/boot_gates.rb +3 -3
  305. data/lib/hecks/runtime/caller.rb +8 -8
  306. data/lib/hecks/runtime/capability_graph.rb +2 -2
  307. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
  308. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +64 -67
  309. data/lib/hecks/runtime/command_interpreter.rb +159 -102
  310. data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
  311. data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
  312. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  313. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  314. data/lib/hecks/runtime/command_rules/references.rb +94 -19
  315. data/lib/hecks/runtime/command_rules.rb +3 -0
  316. data/lib/hecks/runtime/dependency_planning.rb +11 -11
  317. data/lib/hecks/runtime/dispatcher.rb +283 -90
  318. data/lib/hecks/runtime/entity_element.rb +282 -46
  319. data/lib/hecks/runtime/entity_interpreter.rb +160 -69
  320. data/lib/hecks/runtime/errors.rb +19 -19
  321. data/lib/hecks/runtime/event.rb +6 -6
  322. data/lib/hecks/runtime/identity.rb +22 -22
  323. data/lib/hecks/runtime/instance.rb +39 -14
  324. data/lib/hecks/runtime/interpreting.rb +12 -12
  325. data/lib/hecks/runtime/invocation.rb +276 -0
  326. data/lib/hecks/runtime/loader.rb +14 -14
  327. data/lib/hecks/runtime/outbox.rb +23 -23
  328. data/lib/hecks/runtime/policy_interpreter.rb +54 -54
  329. data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
  330. data/lib/hecks/runtime/query_interpreter.rb +111 -56
  331. data/lib/hecks/runtime/reaction_invocation.rb +76 -9
  332. data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
  333. data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
  334. data/lib/hecks/runtime/reference_hop.rb +6 -6
  335. data/lib/hecks/runtime/refusal_wording.rb +92 -112
  336. data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
  337. data/lib/hecks/runtime/registry/verification.rb +36 -26
  338. data/lib/hecks/runtime/registry.rb +56 -27
  339. data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
  340. data/lib/hecks/runtime/routing.rb +10 -88
  341. data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
  342. data/lib/hecks/runtime/saga_interpreter.rb +93 -56
  343. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  344. data/lib/hecks/runtime/tenant_check.rb +9 -9
  345. data/lib/hecks/runtime/tenant_scope.rb +5 -5
  346. data/lib/hecks/runtime/value/admission.rb +75 -30
  347. data/lib/hecks/runtime/value/coercion.rb +379 -226
  348. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  349. data/lib/hecks/runtime/value.rb +28 -23
  350. data/lib/hecks/runtime.rb +7 -7
  351. data/lib/hecks/storehouse.rb +64 -64
  352. data/lib/hecks/version.rb +3 -3
  353. data/lib/hecks/vocabulary.rb +207 -5
  354. data/lib/hecks.rb +13 -11
  355. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
  356. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
  357. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
  358. metadata +43 -2
@@ -27,17 +27,31 @@ module Hecks
27
27
 
28
28
  attr_reader :aggregate, :path
29
29
 
30
+ # Names the optional persistence capabilities `Ports::Persistence::AppendOnly` may rely on.
31
+ #
32
+ # @return [Array<Symbol>] `[:atomic_put]`
30
33
  def persistence_capabilities = [:atomic_put]
31
34
 
35
+ # Opens (creating if absent) the database file and its aggregate, journal, event, saga
36
+ # and outbox tables.
37
+ #
38
+ # @param aggregate [Bluebook::Aggregate] the aggregate whose table this adapter owns
39
+ # @param settings [Hash{Symbol, String => Object}] world settings for the binding:
40
+ # `database` (file path, default `data/<table>.db`) and `domain` (scopes saga rows,
41
+ # default the aggregate's name), each read under a Symbol or a String key
42
+ # @param root [String, nil] directory a relative `database` path resolves against; nil
43
+ # means the process working directory
44
+ # @raise [LoadError] if the `sqlite3` gem is not installed
45
+ # @raise [SQLite3::Exception] if the file cannot be opened or a table cannot be created
32
46
  def initialize(aggregate:, settings: {}, root: nil)
33
- # LAZY, ON PURPOSE — a domain that never wires Sqlite should never
47
+ # **Lazy, on purpose** — a domain that never wires Sqlite should never
34
48
  # need the gem installed. `require "hecks"` alone must not
35
49
  # force a database client library nobody asked for.
36
50
  require "sqlite3"
37
51
 
38
52
  @aggregate = aggregate
39
53
  @path = resolve_path(settings, root)
40
- # THE OPTIONAL saga-persistence capability's own scoping column
54
+ # The optional saga-persistence capability's own scoping column
41
55
  # (§2/§4) — falls back to the aggregate's own name for a
42
56
  # directly-instantiated adapter (specs), same fallback shape
43
57
  # Postgres's own @domain already uses.
@@ -67,18 +81,33 @@ module Hecks
67
81
  create_outbox_table!
68
82
  end
69
83
 
70
- # RE-ENTRANT ON PURPOSE `atomic_put` opens its own transaction
84
+ # Runs the block inside one SQLite transaction, joining an already-open one.
85
+ #
86
+ # **Re-entrant on purpose** — `atomic_put` opens its own transaction
71
87
  # and `Interpreting#run_dispatch_order` opens one around the whole
72
88
  # save+emit pair; SQLite3 refuses a BEGIN inside a BEGIN, so the
73
89
  # inner call joins the outer one instead. Same shape Postgres uses.
90
+ #
91
+ # @yield the writes to commit together; an exception raised inside rolls the
92
+ # outermost transaction back
93
+ # @return [Object] the block's own result
94
+ # @raise [SQLite3::Exception] if `BEGIN`, a statement inside the block, or `COMMIT` fails
74
95
  def transaction(&)
75
96
  return yield if @db.transaction_active?
76
97
 
77
98
  @db.transaction(&)
78
99
  end
79
100
 
101
+ # Names the aggregate's table; the journal table and outbox rows are keyed off it.
102
+ #
103
+ # @return [String] the aggregate's snake_case storage name, unquoted
80
104
  def table = @aggregate.storage_name
81
105
 
106
+ # Reads the current row for one aggregate identity.
107
+ #
108
+ # @param id [String, Object] the aggregate identity, bound as `id.to_s`
109
+ # @return [Runtime::Instance, nil] the decoded record, or nil when no row has that id
110
+ # @raise [SQLite3::Exception] if the statement fails
82
111
  def find(id)
83
112
  row = @db.get_first_row("SELECT * FROM #{quoted_table} WHERE id = ?", [id.to_s])
84
113
  return nil unless row
@@ -86,9 +115,19 @@ module Hecks
86
115
  Runtime::Instance.new(aggregate: @aggregate, id: row["id"], state: decode(row))
87
116
  end
88
117
 
89
- # order_by IS A RUNTIME VALUE see postgres.rb's own all for the
118
+ # Lists every stored record, ordered by id unless an ordering attribute is given.
119
+ #
120
+ # order_by is a runtime value — see postgres.rb's own all for the
90
121
  # full reasoning; whitelisted the identical way before it ever
91
122
  # reaches order_expression.
123
+ #
124
+ # @param order_by [String, Symbol, nil] attribute (or dotted value-object path) to sort
125
+ # by; nil orders by id alone
126
+ # @param direction [Symbol, String] `:asc` or `:desc`, case-insensitive; anything else
127
+ # sorts ascending
128
+ # @return [Array<Runtime::Instance>] the decoded records, `[]` when the table is empty
129
+ # @raise [Runtime::WiringError] if `order_by` names no attribute of the aggregate
130
+ # @raise [SQLite3::Exception] if the statement fails
92
131
  def all(order_by: nil, direction: :asc)
93
132
  order_sql = "ORDER BY id"
94
133
  if order_by
@@ -107,21 +146,38 @@ module Hecks
107
146
  end
108
147
  end
109
148
 
149
+ # Counts the rows in the aggregate's table, deleted records excluded.
150
+ #
151
+ # @return [Integer] number of current records
152
+ # @raise [SQLite3::Exception] if the statement fails
110
153
  def count = @db.get_first_value("SELECT COUNT(*) FROM #{quoted_table}").to_i
111
154
 
155
+ # Inserts one journal row, outside any transaction of its own.
156
+ #
157
+ # @param entry [Ports::Persistence::Entry] the save or delete to journal; `state` is
158
+ # encoded through the state codec and `mirrors` stored as JSON, or NULL when nil
159
+ # @return [Ports::Persistence::Entry] the same `entry`
160
+ # @raise [SQLite3::Exception] if the insert fails
112
161
  def append(entry)
113
162
  @db.execute(
114
163
  "INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES (?, ?, ?, ?)",
115
- # `mirrors` (unlike `state`) is a NULLABLE column — an absent
164
+ # `mirrors` (unlike `state`) is a nullable column — an absent
116
165
  # mirrors hash must bind a real SQL NULL, not the four-character
117
166
  # JSON text `"null"` (`JSON.generate(nil)`), or a future `IS NULL`
118
167
  # check against it would never match. Same guard `postgres_era.rb`
119
168
  # already uses for its own journal's `mirrors` column.
120
- [entry.id, entry.operation, JSON.generate(entry.state), entry.mirrors && JSON.generate(entry.mirrors)]
169
+ [entry.id, entry.operation, JSON.generate(Ports::Persistence::StateCodec.encode(@aggregate, entry.state)),
170
+ entry.mirrors && JSON.generate(entry.mirrors)]
121
171
  )
122
172
  entry
123
173
  end
124
174
 
175
+ # Replaces or deletes the aggregate's row for one journal entry.
176
+ #
177
+ # @param entry [Ports::Persistence::Entry] the save or delete to materialize
178
+ # @return [Runtime::Instance, Array] for a save, a new instance over the entry's state;
179
+ # for a delete, the `DELETE` statement's empty result rows
180
+ # @raise [SQLite3::Exception] if the statement fails
125
181
  def project(entry)
126
182
  return @db.execute("DELETE FROM #{quoted_table} WHERE id = ?", [entry.id]) if entry.delete?
127
183
 
@@ -137,24 +193,41 @@ module Hecks
137
193
  instance
138
194
  end
139
195
 
196
+ # Reads the whole journal back in append order, for `AppendOnly#recover!` to replay.
197
+ #
198
+ # @return [Array<Ports::Persistence::Entry>] every journalled entry, state decoded
199
+ # through the state codec and `mirrors` parsed with String keys (nil when none were
200
+ # stored); a NULL `operation` reads as `"save"`; `[]` when nothing has been appended
201
+ # @raise [SQLite3::Exception] if the statement fails
202
+ # @raise [JSON::ParserError] if a stored `state` or `mirrors` value is not valid JSON
140
203
  def entries
141
204
  @db.execute("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
142
205
  state = JSON.parse(row["state"])
143
206
  Ports::Persistence::Entry.new(
144
207
  operation: row["operation"] || "save",
145
208
  id: row["aggregate_id"],
146
- state: state&.transform_keys(&:to_sym),
209
+ state: Ports::Persistence::StateCodec.decode(@aggregate, state),
147
210
  mirrors: row["mirrors"] && JSON.parse(row["mirrors"])
148
211
  )
149
212
  end
150
213
  end
151
214
 
215
+ # Deletes every row of the aggregate's table and its journal; events, saga rows and
216
+ # outbox rows are left in place.
217
+ #
218
+ # @return [Adapters::Sqlite] self
219
+ # @raise [SQLite3::Exception] if a statement fails
152
220
  def reset!
153
221
  @db.execute("DELETE FROM #{quoted_table}")
154
222
  @db.execute("DELETE FROM #{quoted_entry_table}")
155
223
  self
156
224
  end
157
225
 
226
+ # Journals and replaces an instance's current state in one transaction.
227
+ #
228
+ # @param instance [Runtime::Instance] the instance to store
229
+ # @return [Runtime::Instance] a new instance over a shallow copy of the saved state
230
+ # @raise [SQLite3::Exception] if either statement fails; the transaction is rolled back
158
231
  def save(instance)
159
232
  entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
160
233
  transaction do
@@ -163,9 +236,17 @@ module Hecks
163
236
  end
164
237
  end
165
238
 
239
+ # Stores an entry and reports whether it inserted, replaced or conflicted.
240
+ #
166
241
  # The outcome lookup, journal append and snapshot replacement share one
167
242
  # SQLite transaction. The runtime performs no preliminary find; this
168
243
  # adapter-native operation owns both concurrency and outcome reporting.
244
+ #
245
+ # @param entry [Ports::Persistence::Entry] the save to store
246
+ # @param insert_only [Boolean] when true, an existing row is left untouched
247
+ # @return [Symbol] `:inserted`, `:replaced`, or `:conflicted` when `insert_only` met an
248
+ # existing row and nothing was written
249
+ # @raise [SQLite3::Exception] if a statement fails; the transaction is rolled back
169
250
  def atomic_put(entry, insert_only: false)
170
251
  status = nil
171
252
  transaction do
@@ -181,6 +262,12 @@ module Hecks
181
262
  status
182
263
  end
183
264
 
265
+ # Journals a delete and removes the row, whether or not a row exists. The two
266
+ # statements share a transaction only when the caller has one open.
267
+ #
268
+ # @param id [String, Object] the aggregate identity, journalled as `id.to_s`
269
+ # @return [Boolean] always true
270
+ # @raise [SQLite3::Exception] if either statement fails
184
271
  def delete(id)
185
272
  entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
186
273
  append(entry)
@@ -188,6 +275,11 @@ module Hecks
188
275
  true
189
276
  end
190
277
 
278
+ # Inserts an emitted event into the database's shared `events` table.
279
+ #
280
+ # @param event [Runtime::Event] the emitted event; `payload` is stored as JSON
281
+ # @return [Array] the insert's empty result rows; callers ignore it
282
+ # @raise [SQLite3::Exception] if the insert fails
191
283
  def record_event(event)
192
284
  @db.execute(
193
285
  "INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES (?, ?, ?, ?, ?)",
@@ -195,6 +287,12 @@ module Hecks
195
287
  )
196
288
  end
197
289
 
290
+ # Reads back every recorded event in insertion order — the database file's whole
291
+ # `events` table, not only this aggregate's rows.
292
+ #
293
+ # @return [Array<Runtime::Event>] the stored events, `payload` parsed with Symbol keys
294
+ # and `occurred_at` as stored; `[]` when none are recorded
295
+ # @raise [SQLite3::Exception] if the statement fails
198
296
  def events
199
297
  @db.execute("SELECT * FROM events ORDER BY id").map do |row|
200
298
  Runtime::Event.new(
@@ -207,26 +305,22 @@ module Hecks
207
305
  end
208
306
  end
209
307
 
210
- # ── the OPTIONAL saga-persistence capability (§2) reuses the
211
- # DDL every SQLite-backed aggregate table already lives beside
212
- # (`create_saga_table!`, `Sqlite::SchemaBuilder`, shared with D1).
213
- # SQLite's `resolve_path` defaults to one `.db` file PER
214
- # AGGREGATE unless a domain shares one `database` setting across
215
- # its aggregates — since saga persistence resolves through
216
- # whichever adapter instance backs the domain's FIRST aggregate
217
- # (`Registry#saga_persistence`), this table ends up living inside
218
- # THAT one aggregate's own file by default. Correct and durable
219
- # either way; a domain that wants an obviously-named saga store
220
- # already gets one by sharing `database` across its aggregates,
221
- # the recommended, common case.
222
- # THE OUTBOX — see `Runtime::Outbox`. Rows land in the SAME
308
+ # Inserts new outbox rows as pending, skipping any whose `delivery_id` already exists.
309
+ #
310
+ # The outbox — see `Runtime::Outbox`. Rows land in the same
223
311
  # database as this aggregate (the only way the enqueue shares the
224
312
  # save's transaction), keyed by the aggregate's storage name so an
225
313
  # adapter instance only ever reads back its own rows even when
226
314
  # several aggregates share one file. `INSERT OR IGNORE` on the
227
- # UNIQUE delivery_id makes a re-enqueue of the same (event,
315
+ # unique delivery_id makes a re-enqueue of the same (event,
228
316
  # consumer) a no-op; `outbox_claim`'s `WHERE status = 'pending'`
229
317
  # is the compare-and-set that lets exactly one relay win a row.
318
+ #
319
+ # @param rows [Array<Runtime::Outbox::Row>] rows to enqueue; each accepted row has its
320
+ # `id` and `status` assigned in place. `row.aggregate` is stored as given
321
+ # @return [Array<Runtime::Outbox::Row>] the rows actually inserted, `[]` when every one
322
+ # was a duplicate
323
+ # @raise [SQLite3::Exception] if an insert fails
230
324
  def outbox_enqueue(rows)
231
325
  rows.filter_map do |row|
232
326
  @db.execute(
@@ -243,6 +337,12 @@ module Hecks
243
337
  end
244
338
  end
245
339
 
340
+ # Claims a pending outbox row with a compare-and-set update, counting the attempt.
341
+ #
342
+ # @param id [Integer] the row id `outbox_enqueue` assigned
343
+ # @return [Boolean] true when the row was pending and is now claimed; false when it is
344
+ # unknown or another claimer got there first
345
+ # @raise [SQLite3::Exception] if the update fails
246
346
  def outbox_claim(id)
247
347
  @db.execute(
248
348
  "UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = ? " \
@@ -252,6 +352,15 @@ module Hecks
252
352
  @db.changes == 1
253
353
  end
254
354
 
355
+ # Records a delivery outcome and its settle time on an outbox row, whatever status it
356
+ # held.
357
+ #
358
+ # @param id [Integer] the row id `outbox_enqueue` assigned
359
+ # @param status [String, Symbol] the new status, one of `Runtime::Outbox::STATUSES`;
360
+ # not validated here
361
+ # @param error [String, nil] the failure description, or nil to store NULL
362
+ # @return [Boolean] true when exactly one row was updated; false when no row has `id`
363
+ # @raise [SQLite3::Exception] if the update fails
255
364
  def outbox_settle(id, status:, error: nil)
256
365
  @db.execute(
257
366
  "UPDATE hecks_outbox SET status = ?, error = ?, settled_at = ? WHERE id = ?",
@@ -260,6 +369,13 @@ module Hecks
260
369
  @db.changes == 1
261
370
  end
262
371
 
372
+ # Lists the outbox rows whose `aggregate` column equals this adapter's `table`, in
373
+ # enqueue order.
374
+ #
375
+ # @param status [String, Symbol, nil] only rows with this status; nil lists every row
376
+ # @return [Array<Runtime::Outbox::Row>] the matching rows, `event` parsed with Symbol
377
+ # keys; `[]` when none match
378
+ # @raise [SQLite3::Exception] if the statement fails
263
379
  def outbox_rows(status: nil)
264
380
  sql = "SELECT * FROM hecks_outbox WHERE aggregate = ?"
265
381
  binds = [table]
@@ -270,6 +386,31 @@ module Hecks
270
386
  @db.execute("#{sql} ORDER BY id", binds).map { |row| outbox_row(row) }
271
387
  end
272
388
 
389
+ # Replaces one saga instance's checkpoint, keyed by domain, process manager and
390
+ # correlation.
391
+ #
392
+ # ── the optional saga-persistence capability (§2) — reuses the
393
+ # DDL every SQLite-backed aggregate table already lives beside
394
+ # (`create_saga_table!`, `Sqlite::SchemaBuilder`, shared with D1).
395
+ # SQLite's `resolve_path` defaults to one `.db` file per
396
+ # aggregate unless a domain shares one `database` setting across
397
+ # its aggregates — since saga persistence resolves through
398
+ # whichever adapter instance backs the domain's first aggregate
399
+ # (`Registry#saga_persistence`), this table ends up living inside
400
+ # that one aggregate's own file by default. Correct and durable
401
+ # either way; a domain that wants an obviously-named saga store
402
+ # already gets one by sharing `database` across its aggregates,
403
+ # the recommended, common case.
404
+ #
405
+ # @param process_manager [String, Symbol] the process manager's name
406
+ # @param correlation [String, Object] the instance's correlation value, stored as
407
+ # `correlation.to_s`
408
+ # @param state [String, Symbol] the saga's current state name
409
+ # @param memory [Hash] the saga's memory; must be JSON-serializable
410
+ # @param completed_compensations [Array] the ledger of completed compensable legs; must
411
+ # be JSON-serializable
412
+ # @return [Array] the statement's empty result rows; callers ignore it
413
+ # @raise [SQLite3::Exception] if the statement fails
273
414
  def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
274
415
  @db.execute(
275
416
  "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, " \
@@ -279,6 +420,13 @@ module Hecks
279
420
  )
280
421
  end
281
422
 
423
+ # Removes a finished saga instance's checkpoint; a missing row is not an error.
424
+ #
425
+ # @param process_manager [String, Symbol] the process manager's name
426
+ # @param correlation [String, Object] the instance's correlation value, matched as
427
+ # `correlation.to_s`
428
+ # @return [Array] the statement's empty result rows; callers ignore it
429
+ # @raise [SQLite3::Exception] if the statement fails
282
430
  def delete_saga(process_manager:, correlation:)
283
431
  @db.execute(
284
432
  "DELETE FROM hecks_saga_instances WHERE domain = ? AND process_manager = ? AND correlation = ?",
@@ -286,6 +434,18 @@ module Hecks
286
434
  )
287
435
  end
288
436
 
437
+ # Yields every checkpointed saga instance of this adapter's domain, for
438
+ # `Registry#rehydrate_sagas!` to restore at boot.
439
+ #
440
+ # @yieldparam process_manager [String] the process manager's name
441
+ # @yieldparam correlation [String] the instance's correlation value
442
+ # @yieldparam state [String] the saga's state name
443
+ # @yieldparam memory [Hash{Symbol => Object}] the saga's memory, Symbol keys at every depth
444
+ # @yieldparam completed_compensations [Array] the completed-compensation ledger, `[]`
445
+ # when the column is NULL
446
+ # @return [Enumerator, Array<Hash>] an enumerator over the same five values when no
447
+ # block is given; otherwise the raw result rows
448
+ # @raise [SQLite3::Exception] if the statement fails
289
449
  def each_saga
290
450
  return enum_for(:each_saga) unless block_given?
291
451
 
@@ -14,7 +14,7 @@ end
14
14
  require_relative "driven/memory"
15
15
  require_relative "driven/sqlite"
16
16
  require_relative "driven/postgres"
17
- # `PostgresEra` is NOT `require_relative`d here — ADR 0033 moved it, and
17
+ # `PostgresEra` is not `require_relative`d here — ADR 0033 moved it, and
18
18
  # the rest of the era/lineage/translation subsystem, behind a loadable
19
19
  # persistence plugin (`hecks/ports/persistence/plugins/era`) rather than
20
20
  # requiring every app to carry it whether or not anything ever binds
@@ -22,10 +22,10 @@ require_relative "driven/postgres"
22
22
  # that makes that genuinely load-nothing-until-asked instead of pushing
23
23
  # an explicit `require` onto every one of the ~15 generic `bin/*` tools
24
24
  # that boot an arbitrary checked-in domain (several of which — pizzas,
25
- # compliance, chess, roster — bind PostgresEra): the FIRST real
25
+ # compliance, chess, roster — bind PostgresEra): the first real
26
26
  # `Adapters.const_get("PostgresEra")` (`registry/verification.rb`'s own
27
27
  # adapter resolution, unchanged) transparently `require`s the plugin
28
- # entry point, which defines the constant AND calls `register_plugin`
28
+ # entry point, which defines the constant and calls `register_plugin`
29
29
  # as its own side effect (`plugins/era.rb`'s last line) — the exact
30
30
  # same effect an explicit `require "hecks/ports/persistence/plugins/
31
31
  # era"` has, just deferred to the moment something is actually asked
@@ -43,7 +43,7 @@ require_relative "driven/mock_stripe_adapter"
43
43
  require_relative "driven/secure_random_identity"
44
44
  require_relative "driven/system_clock"
45
45
  # `SequentialIdentity` — the deterministic identity_generation test
46
- # double — is NOT required here on purpose. It lives at
46
+ # double — is not required here on purpose. It lives at
47
47
  # spec/fixtures/sequential_identity.{adapter,rb}, loaded explicitly by
48
48
  # whichever spec wants it: this file's `require_relative` list is what
49
49
  # `Folder#load_library`'s `.adapter` glob backs, and `.adapter` DSL
@@ -0,0 +1,145 @@
1
+ require "openssl"
2
+ require "json"
3
+ require "rack"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # **The driving side** — code an outside caller reaches in through,
8
+ # rather than code the domain reaches out through. Every existing
9
+ # file under `adapters/driven/` is the latter: a store or reader a
10
+ # `persisted_by`/`port` binding resolves to, called by this
11
+ # framework's own runtime. Nothing under this repository has ever
12
+ # been the mirror image before — code that receives a request from
13
+ # the outside world and turns it into a dispatch — so this is the
14
+ # first entry, and the directory itself is new.
15
+ module Driving
16
+ # **A GitHub webhook receiver, transport only** — the same split
17
+ # `Hecks::Adapters::GithubChecks` (qa/adapters/github_checks.rb,
18
+ # this class's own pull-side sibling) already draws for itself:
19
+ # this file owns proving a request really came from GitHub and
20
+ # unwrapping GitHub's own webhook envelope (`X-GitHub-Event`, the
21
+ # JSON body, GitHub's own automatic `ping` check) — never which
22
+ # commands to dispatch about what it finds inside. That is exactly
23
+ # as domain-specific as `GithubChecks#run` turning `check-runs`
24
+ # JSON into green-or-raise, and lives exactly where that class's
25
+ # own header explains such logic belongs: outside this library,
26
+ # in `qa/adapters/github_ci_webhook.rb`, the subclass of this file
27
+ # that actually knows what a `QualityControl::Clearance` is.
28
+ #
29
+ # A plain rack app (`#call(env)`) — no Sinatra, no Rails — the same
30
+ # shape `Hecks::Forms::App` (lib/hecks/forms/app.rb) already
31
+ # established for the one other HTTP-facing surface this library
32
+ # ships. `rack` is a lazy Gemfile dependency for exactly the reason
33
+ # that file's own header gives: this file is never required by
34
+ # `require "hecks"` (nothing under `adapters.rb`'s own eager
35
+ # `adapters/driven` load names it — see that file's own header),
36
+ # so a project that never mounts a driving adapter never needs
37
+ # `rack` installed, the same "opt in by requiring the file at all"
38
+ # contract `hecks/forms.rb` already has for `Forms::App`.
39
+ #
40
+ # **Subclass responsibility**: implement `#handle_event(event, action,
41
+ # payload)`, returning `[http_status, response_body_hash]`. Called
42
+ # only after the signature has verified and the body has parsed as
43
+ # JSON — a subclass never has to re-check either. `event` is
44
+ # GitHub's own `X-GitHub-Event` header value ("check_suite",
45
+ # "check_run", "pull_request", ...); `action` is the payload's own
46
+ # top-level `"action"` field when it has one (GitHub's webhooks
47
+ # nearly all carry one — "completed", "requested", "opened", ...)
48
+ # and nil when it does not. `ping` — GitHub's own automatic
49
+ # connectivity check, sent once when a webhook is first saved in
50
+ # repository settings — is answered here and never reaches a
51
+ # subclass at all; there is nothing domain-specific to decide
52
+ # about it.
53
+ class GithubWebhook
54
+ # **Refused, loudly** — the same shape a domain refusal already takes
55
+ # everywhere else in this codebase (`Runtime::DOMAIN_REFUSALS`,
56
+ # `Forms::App`'s own `{error:, message:}` JSON body for a bad
57
+ # command). A request that cannot prove it came from GitHub gets
58
+ # a real 401 and a named reason, never a silent 200 that would
59
+ # let a forged "CI passed" payload regress nothing while looking
60
+ # exactly like success in a log nobody re-reads.
61
+ class InvalidSignature < StandardError; end
62
+
63
+ # **The body did not even parse** — distinct from a signature refusal:
64
+ # this body genuinely came from whoever signed it (checked
65
+ # first, before parsing ever runs — see `#call`), and simply
66
+ # is not JSON. Still refused, never guessed at.
67
+ class MalformedPayload < StandardError; end
68
+
69
+ SIGNATURE_HEADER = "HTTP_X_HUB_SIGNATURE_256".freeze
70
+ EVENT_HEADER = "HTTP_X_GITHUB_EVENT".freeze
71
+
72
+ # `secret:` has no default, on purpose — the same rule
73
+ # `GoogleAuthentication`'s own header states for its own
74
+ # `ENV.fetch`, restated here because the consequence is worse for
75
+ # a webhook: an unverified signature check is not "half
76
+ # configured", it is no verification at all, silently accepting
77
+ # anything claiming to be GitHub. A caller passes the real
78
+ # secret explicitly — from `ENV.fetch("GITHUB_WEBHOOK_SECRET")`
79
+ # or wherever it keeps one — rather than this class reaching into
80
+ # the environment itself and hiding that requirement inside a
81
+ # default.
82
+ def initialize(secret:)
83
+ raise ArgumentError, "no webhook secret configured" if secret.to_s.empty?
84
+
85
+ @secret = secret
86
+ end
87
+
88
+ def call(env)
89
+ request = Rack::Request.new(env)
90
+ return respond(405, error: "MethodNotAllowed", message: "POST only") unless request.post?
91
+
92
+ body = request.body.read
93
+ verify_signature!(request, body)
94
+
95
+ event = request.get_header(EVENT_HEADER)
96
+ return respond(200, ok: true, event: "ping") if event == "ping"
97
+ return respond(400, error: "MissingEvent", message: "no #{EVENT_HEADER} header") if event.to_s.empty?
98
+
99
+ payload = parse_json(body)
100
+ status, result = handle_event(event, payload["action"], payload)
101
+ respond(status, result)
102
+ rescue InvalidSignature => e
103
+ respond(401, error: "InvalidSignature", message: e.message)
104
+ rescue MalformedPayload => e
105
+ respond(400, error: "MalformedPayload", message: e.message)
106
+ end
107
+
108
+ private
109
+
110
+ # Constant-time compare, not `==`. A byte-by-byte `==` returns
111
+ # the moment it finds the first mismatching byte, so how long
112
+ # that took leaks how many leading bytes of a forged signature
113
+ # were already right to anyone timing the response — GitHub's
114
+ # own webhook documentation calls this out by name and recommends
115
+ # exactly the constant-time compare `Rack::Utils.secure_compare`
116
+ # already gives for free, reused rather than hand-rolled.
117
+ def verify_signature!(request, body)
118
+ header = request.get_header(SIGNATURE_HEADER)
119
+ raise InvalidSignature, "missing #{SIGNATURE_HEADER.sub('HTTP_', '').tr('_', '-')} header" if header.to_s.empty?
120
+
121
+ digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, body)
122
+ expected = "sha256=#{digest}"
123
+ return if Rack::Utils.secure_compare(expected, header)
124
+
125
+ raise InvalidSignature,
126
+ "signature does not match — refusing a payload that cannot be proven to be GitHub's own"
127
+ end
128
+
129
+ def parse_json(body)
130
+ JSON.parse(body)
131
+ rescue JSON::ParserError => e
132
+ raise MalformedPayload, e.message
133
+ end
134
+
135
+ def handle_event(event, action, payload)
136
+ raise NotImplementedError, "#{self.class} must implement #handle_event(event, action, payload)"
137
+ end
138
+
139
+ def respond(status, body)
140
+ [status, { "content-type" => "application/json" }, [JSON.generate(body)]]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -59,7 +59,7 @@ module Hecks
59
59
  # nothing and passes whenever dispatch doesn't raise; `count:` on a
60
60
  # command and `emits:` on a query are each read by neither runner
61
61
  # (Expectations#run_command/#run_query), so they're accepted here
62
- # and then silently ignored at run time. Checking both at BUILD
62
+ # and then silently ignored at run time. Checking both at build
63
63
  # time, not in the runners, makes them errors on the file that
64
64
  # wrote them rather than green checks nobody questions.
65
65
  def validate_expect!
@@ -95,7 +95,7 @@ module Hecks
95
95
 
96
96
  def vision(text) = @vision = text
97
97
 
98
- # Relative to THIS `.behaviors` file, never to the filesystem's cwd
98
+ # Relative to this `.behaviors` file, never to the filesystem's cwd
99
99
  # or a same-stem convention — scope is a fact this file declares,
100
100
  # not one a runner infers.
101
101
  def loads(*paths)