hecks 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (373) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +72 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +83 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
  7. data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
  9. data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
  10. data/lib/hecks/adapters/driven/heki.rb +106 -9
  11. data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
  12. data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
  13. data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
  15. data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
  16. data/lib/hecks/adapters/driven/lambda.rb +82 -35
  17. data/lib/hecks/adapters/driven/local_storage.rb +83 -10
  18. data/lib/hecks/adapters/driven/memory.rb +205 -9
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  24. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  25. data/lib/hecks/adapters/driven/prism.rb +50 -11
  26. data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  31. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  32. data/lib/hecks/adapters/driven/system_clock.rb +3 -0
  33. data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
  34. data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
  35. data/lib/hecks/adapters/driven.rb +6 -4
  36. data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
  37. data/lib/hecks/behaviors/dsl.rb +60 -2
  38. data/lib/hecks/behaviors/expectations.rb +190 -29
  39. data/lib/hecks/behaviors/ir.rb +12 -1
  40. data/lib/hecks/behaviors/rspec.rb +9 -1
  41. data/lib/hecks/behaviors/runner.rb +21 -2
  42. data/lib/hecks/behaviors.rb +9 -1
  43. data/lib/hecks/bluebook/aggregate.rb +43 -13
  44. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
  45. data/lib/hecks/bluebook/assembly/build.rb +26 -5
  46. data/lib/hecks/bluebook/assembly/contract.rb +98 -23
  47. data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
  48. data/lib/hecks/bluebook/assembly/marks.rb +159 -30
  49. data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
  50. data/lib/hecks/bluebook/assembly.rb +32 -14
  51. data/lib/hecks/bluebook/attribute.rb +26 -12
  52. data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
  53. data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
  54. data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
  55. data/lib/hecks/bluebook/behaviour/command.rb +55 -25
  56. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
  57. data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
  58. data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
  59. data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
  60. data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
  61. data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
  62. data/lib/hecks/bluebook/behaviour/query.rb +6 -1
  63. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
  64. data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
  65. data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
  66. data/lib/hecks/bluebook/capabilities.rb +27 -0
  67. data/lib/hecks/bluebook/chapter.rb +51 -9
  68. data/lib/hecks/bluebook/command.rb +62 -17
  69. data/lib/hecks/bluebook/domain_port.rb +34 -9
  70. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  71. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  72. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
  73. data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
  74. data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
  75. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
  76. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
  77. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  78. data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
  79. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  80. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  81. data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
  82. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  83. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
  84. data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
  85. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  86. data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
  87. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  88. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  89. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
  90. data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
  91. data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
  92. data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
  93. data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
  94. data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
  95. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  96. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  97. data/lib/hecks/bluebook/entity.rb +40 -11
  98. data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
  99. data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
  100. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
  101. data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
  102. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
  103. data/lib/hecks/bluebook/expression/resolver.rb +369 -128
  104. data/lib/hecks/bluebook/hexagon.rb +35 -1
  105. data/lib/hecks/bluebook/lifecycle.rb +12 -1
  106. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
  107. data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
  108. data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
  109. data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
  110. data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
  111. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
  112. data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
  113. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
  114. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
  115. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
  116. data/lib/hecks/bluebook/meta_validator.rb +235 -139
  117. data/lib/hecks/bluebook/model_check.rb +434 -104
  118. data/lib/hecks/bluebook/pattern_subset.rb +32 -10
  119. data/lib/hecks/bluebook/policy.rb +15 -13
  120. data/lib/hecks/bluebook/process_manager.rb +27 -14
  121. data/lib/hecks/bluebook/project_discovery.rb +5 -0
  122. data/lib/hecks/bluebook/project_loader.rb +40 -0
  123. data/lib/hecks/bluebook/project_register.rb +50 -6
  124. data/lib/hecks/bluebook/query.rb +31 -4
  125. data/lib/hecks/bluebook/read_model.rb +35 -15
  126. data/lib/hecks/bluebook/reference.rb +26 -13
  127. data/lib/hecks/bluebook/smoke_test.rb +46 -23
  128. data/lib/hecks/bluebook/synthesizer.rb +46 -12
  129. data/lib/hecks/bluebook/translation.rb +34 -5
  130. data/lib/hecks/bluebook/value_object.rb +29 -11
  131. data/lib/hecks/bluebook.rb +5 -6
  132. data/lib/hecks/codemod.rb +138 -50
  133. data/lib/hecks/construct.rb +21 -7
  134. data/lib/hecks/corpus.rb +438 -0
  135. data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
  136. data/lib/hecks/doc/reference.rb +200 -31
  137. data/lib/hecks/embryonaut_bluebook.rb +38 -15
  138. data/lib/hecks/facade/cli_door.rb +69 -10
  139. data/lib/hecks/facade/cli_runner.rb +105 -24
  140. data/lib/hecks/facade/command_request.rb +23 -0
  141. data/lib/hecks/facade/handle.rb +155 -35
  142. data/lib/hecks/facade/json_door.rb +106 -25
  143. data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
  144. data/lib/hecks/facade/surface/chapter.rb +26 -17
  145. data/lib/hecks/facade/surface.rb +16 -3
  146. data/lib/hecks/facade.rb +15 -4
  147. data/lib/hecks/forms/app.rb +46 -30
  148. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  149. data/lib/hecks/forms/field_renderer.rb +142 -6
  150. data/lib/hecks/forms/field_shape.rb +183 -19
  151. data/lib/hecks/forms/html.rb +51 -7
  152. data/lib/hecks/forms/index_renderer.rb +14 -2
  153. data/lib/hecks/forms/page.rb +14 -0
  154. data/lib/hecks/forms/params.rb +120 -23
  155. data/lib/hecks/forms/port_argument.rb +14 -2
  156. data/lib/hecks/forms/query_form_renderer.rb +65 -2
  157. data/lib/hecks/forms/record_renderer.rb +60 -2
  158. data/lib/hecks/forms/record_table.rb +28 -1
  159. data/lib/hecks/forms/reference_options.rb +24 -0
  160. data/lib/hecks/forms/value_object_shape.rb +13 -3
  161. data/lib/hecks/forms.rb +24 -4
  162. data/lib/hecks/fqn.rb +59 -1
  163. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  164. data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
  165. data/lib/hecks/framework/oidc.json +15 -0
  166. data/lib/hecks/framework.rb +79 -25
  167. data/lib/hecks/freezer.rb +27 -11
  168. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
  169. data/lib/hecks/fuzzing/combination_miner.rb +178 -0
  170. data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
  171. data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
  172. data/lib/hecks/fuzzing/differential.rb +192 -0
  173. data/lib/hecks/fuzzing/domain_generator.rb +871 -0
  174. data/lib/hecks/fuzzing/era_boundary.rb +68 -19
  175. data/lib/hecks/fuzzing/form_census.rb +121 -20
  176. data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
  177. data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
  178. data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
  179. data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
  180. data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
  181. data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
  182. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
  183. data/lib/hecks/fuzzing/properties/guards.rb +86 -42
  184. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
  185. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
  186. data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
  187. data/lib/hecks/fuzzing/properties/querying.rb +82 -28
  188. data/lib/hecks/fuzzing/properties.rb +84 -51
  189. data/lib/hecks/fuzzing/qa_settings.rb +164 -0
  190. data/lib/hecks/fuzzing/replay.rb +241 -133
  191. data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
  192. data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
  193. data/lib/hecks/fuzzing/self_consistency.rb +315 -137
  194. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
  195. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  196. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  197. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  198. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
  199. data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
  200. data/lib/hecks/fuzzing/shrinker.rb +309 -0
  201. data/lib/hecks/fuzzing/structural_skips.rb +37 -130
  202. data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
  203. data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
  204. data/lib/hecks/fuzzing/value_generator.rb +110 -22
  205. data/lib/hecks/fuzzing.rb +5 -0
  206. data/lib/hecks/grammar/evolve.rb +188 -12
  207. data/lib/hecks/grammar.rb +53 -7
  208. data/lib/hecks/ir.rb +51 -20
  209. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  210. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  211. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  212. data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
  213. data/lib/hecks/language/oidc.json +5 -0
  214. data/lib/hecks/literal.rb +41 -9
  215. data/lib/hecks/naming.rb +112 -31
  216. data/lib/hecks/ports/access_control.rb +53 -2
  217. data/lib/hecks/ports/agent/answers.rb +83 -6
  218. data/lib/hecks/ports/agent.rb +119 -35
  219. data/lib/hecks/ports/authentication.rb +44 -4
  220. data/lib/hecks/ports/authorization.rb +50 -11
  221. data/lib/hecks/ports/clock.rb +42 -23
  222. data/lib/hecks/ports/extraction.rb +16 -0
  223. data/lib/hecks/ports/identity_assignment.rb +23 -2
  224. data/lib/hecks/ports/identity_generation.rb +17 -3
  225. data/lib/hecks/ports/identity_resolution.rb +17 -1
  226. data/lib/hecks/ports/key_vault.port +6 -0
  227. data/lib/hecks/ports/key_vault.rb +58 -0
  228. data/lib/hecks/ports/loading.rb +4 -0
  229. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  230. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  231. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  232. data/lib/hecks/ports/persistence/execution.rb +4 -0
  233. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  234. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  235. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  236. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  237. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  238. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  239. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  250. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  251. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  252. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
  253. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  254. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  255. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  256. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  257. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  258. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
  259. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  260. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  261. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  262. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
  263. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
  264. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
  265. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
  266. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  267. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  268. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  269. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  270. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  271. data/lib/hecks/ports/persistence.rb +36 -1
  272. data/lib/hecks/ports/projection.rb +61 -7
  273. data/lib/hecks/ports/query/in_memory.rb +41 -8
  274. data/lib/hecks/ports/query/ordering.rb +21 -6
  275. data/lib/hecks/ports/query.rb +35 -0
  276. data/lib/hecks/ports.rb +1 -0
  277. data/lib/hecks/projections/bootstrap_table.rb +147 -0
  278. data/lib/hecks/projections/diagrams.rb +315 -79
  279. data/lib/hecks/projections/glossary/html.rb +91 -3
  280. data/lib/hecks/projections/glossary/markdown.rb +50 -3
  281. data/lib/hecks/projections/glossary/mermaid.rb +49 -1
  282. data/lib/hecks/projections/glossary/sections.rb +1 -1
  283. data/lib/hecks/projections/glossary/sentences.rb +110 -5
  284. data/lib/hecks/projections/glossary.rb +173 -31
  285. data/lib/hecks/projections/ir.rb +1 -1
  286. data/lib/hecks/projections/model/deviations.rb +62 -17
  287. data/lib/hecks/projections/model.rb +75 -21
  288. data/lib/hecks/projections/oidc.rb +45 -8
  289. data/lib/hecks/projections/parser_table.rb +53 -12
  290. data/lib/hecks/projections/reference.rb +15 -3
  291. data/lib/hecks/projections/rust_vocabulary.rb +646 -0
  292. data/lib/hecks/projections/shape.rb +10 -2
  293. data/lib/hecks/projections/statements.rb +69 -22
  294. data/lib/hecks/projections/vocabulary.rb +26 -9
  295. data/lib/hecks/projections.rb +5 -3
  296. data/lib/hecks/projector/cli_projector.rb +245 -37
  297. data/lib/hecks/projector/docs_projector.rb +154 -28
  298. data/lib/hecks/projector/exporter.rb +104 -29
  299. data/lib/hecks/projector/ir_projector.rb +7 -1
  300. data/lib/hecks/projector/narrate_projector.rb +144 -23
  301. data/lib/hecks/projector/target.rb +42 -18
  302. data/lib/hecks/projector.rb +86 -18
  303. data/lib/hecks/query_ir.rb +94 -47
  304. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  305. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  306. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  307. data/lib/hecks/query_specification/common/null_policy.rb +61 -15
  308. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  309. data/lib/hecks/query_specification/common/options.rb +25 -0
  310. data/lib/hecks/query_specification/field_path.rb +69 -15
  311. data/lib/hecks/query_specification/hop_path.rb +57 -20
  312. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  313. data/lib/hecks/rendering.rb +9 -3
  314. data/lib/hecks/router/namespace_installer.rb +16 -3
  315. data/lib/hecks/router.rb +56 -1
  316. data/lib/hecks/runtime/aggregate_lock.rb +20 -11
  317. data/lib/hecks/runtime/boot_gates.rb +21 -3
  318. data/lib/hecks/runtime/caller.rb +40 -8
  319. data/lib/hecks/runtime/capability_graph.rb +13 -2
  320. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
  321. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
  322. data/lib/hecks/runtime/command_interpreter.rb +195 -113
  323. data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
  324. data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
  325. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  326. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  327. data/lib/hecks/runtime/command_rules/references.rb +145 -55
  328. data/lib/hecks/runtime/command_rules.rb +3 -0
  329. data/lib/hecks/runtime/dependency_planning.rb +56 -11
  330. data/lib/hecks/runtime/dispatcher.rb +263 -92
  331. data/lib/hecks/runtime/entity_element.rb +304 -67
  332. data/lib/hecks/runtime/entity_interpreter.rb +149 -88
  333. data/lib/hecks/runtime/errors.rb +37 -23
  334. data/lib/hecks/runtime/event.rb +15 -10
  335. data/lib/hecks/runtime/identity.rb +91 -23
  336. data/lib/hecks/runtime/instance.rb +103 -18
  337. data/lib/hecks/runtime/interpreting.rb +24 -16
  338. data/lib/hecks/runtime/invocation.rb +358 -0
  339. data/lib/hecks/runtime/loader.rb +105 -19
  340. data/lib/hecks/runtime/outbox.rb +164 -26
  341. data/lib/hecks/runtime/policy_interpreter.rb +73 -60
  342. data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
  343. data/lib/hecks/runtime/query_interpreter.rb +93 -74
  344. data/lib/hecks/runtime/reaction_invocation.rb +73 -28
  345. data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
  346. data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
  347. data/lib/hecks/runtime/reference_hop.rb +48 -6
  348. data/lib/hecks/runtime/refusal_wording.rb +142 -115
  349. data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
  350. data/lib/hecks/runtime/registry/verification.rb +153 -28
  351. data/lib/hecks/runtime/registry.rb +202 -32
  352. data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
  353. data/lib/hecks/runtime/routing.rb +36 -154
  354. data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
  355. data/lib/hecks/runtime/saga_interpreter.rb +90 -76
  356. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  357. data/lib/hecks/runtime/tenant_check.rb +33 -13
  358. data/lib/hecks/runtime/tenant_scope.rb +23 -5
  359. data/lib/hecks/runtime/value/admission.rb +75 -30
  360. data/lib/hecks/runtime/value/coercion.rb +555 -142
  361. data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
  362. data/lib/hecks/runtime/value.rb +71 -21
  363. data/lib/hecks/runtime.rb +39 -7
  364. data/lib/hecks/storehouse.rb +368 -72
  365. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
  366. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
  367. data/lib/hecks/version.rb +3 -3
  368. data/lib/hecks/vocabulary.rb +205 -4
  369. data/lib/hecks.rb +91 -11
  370. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
  371. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
  372. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
  373. metadata +28 -2
@@ -14,16 +14,26 @@ module Hecks
14
14
  PORTS = "ports".freeze
15
15
  ADAPTERS = "adapters".freeze
16
16
 
17
+ # @param settings [Hash] accepted but not read by this class
18
+ # @param root [String, nil] accepted but not read by this class
17
19
  def initialize(settings: {}, root: nil)
18
20
  @settings = settings
19
21
  @root = root
20
22
  end
21
23
 
24
+ # Loads the framework's own bundled ports and adapters from `lib/hecks/{ports,adapters}`.
25
+ #
26
+ # @return [void]
22
27
  def load_library
23
28
  load_each(library(PORTS), %w[*.port])
24
29
  load_each(library(ADAPTERS), %w[*/*.adapter */*/*.adapter])
25
30
  end
26
31
 
32
+ # Loads a shared root's own ports and adapters, if one was found.
33
+ #
34
+ # @param root [String, nil] the shared root directory, as `shared_root` resolves it;
35
+ # nil is a no-op
36
+ # @return [void]
27
37
  def load_project(root)
28
38
  return unless root
29
39
 
@@ -31,7 +41,7 @@ module Hecks
31
41
  load_each(File.join(root, ADAPTERS), %w[*.adapter */*.adapter */*/*.adapter])
32
42
  end
33
43
 
34
- # CHAPTERS FIRST, JUDGED ONCE, THEN EVERYTHING THAT READS THEM.
44
+ # Chapters first, judged once, then everything that reads them.
35
45
  # A chapter may be split across files (the language's own grammar is
36
46
  # nine), and judging file one before files two-through-nine exist
37
47
  # refuses references that are perfectly well declared a file later —
@@ -39,8 +49,9 @@ module Hecks
39
49
  # already places every `*.bluebook` ahead of hecksagons and worlds,
40
50
  # so the window ends at the last chapter pattern rather than at a
41
51
  # hand-written list this would otherwise have to keep in step.
42
- # `environment:` — ONE MORE PAIR OF FILES, LOADED LAST, NOT A GLOB.
43
- # RECOVERED, not new — see Runtime::Loader.boot's own comment for
52
+ #
53
+ # `environment:` — one more pair of files, loaded last, not a glob.
54
+ # Recovered, not new — see Runtime::Loader.boot's own comment for
44
55
  # the provenance. A caller passing `Hecks.boot(path, environment:
45
56
  # "production")` gets exactly `environments/production.hecksagon`
46
57
  # and `environments/production.world` loaded, whichever exist (a
@@ -53,11 +64,17 @@ module Hecks
53
64
  # globs (all non-recursive, none named `environments/*`), so this
54
65
  # is the only thing that ever reaches them. Loaded as genuine
55
66
  # `Hecks.hecksagon "SameDomain" do ... end` / `Hecks.world
56
- # "SameDomain" do ... end` blocks — MERGED into the base file's
67
+ # "SameDomain" do ... end` blocks — merged into the base file's
57
68
  # own hecksagon/world (Registry#add_hecksagon / #add_world,
58
69
  # concatenate/override rather than replace), so an overlay can
59
70
  # rebind or add settings for anything the base file declared
60
71
  # without needing to know what else the base file said.
72
+ #
73
+ # @param directory [String] the domain's bluebook directory to load, as
74
+ # `bluebook_directory` resolves it
75
+ # @param environment [String, nil] the environment overlay to load after the domain's
76
+ # own files (e.g. `"production"`); nil loads no overlay
77
+ # @return [void]
61
78
  def load_domain(directory, environment: nil)
62
79
  boundary = DOMAIN_ORDER.rindex { |pattern| pattern.end_with?(".bluebook") }
63
80
  if boundary
@@ -73,18 +90,23 @@ module Hecks
73
90
  load_each(directory, [File.join("environments", "#{environment}.world")])
74
91
  end
75
92
 
76
- # EVERY BLUEBOOK IN A FOLDER IS ONE DECLARATION SET. Individual files
93
+ # Every bluebook in a folder is one declaration set. Individual files
77
94
  # remain organized in the domain expert's language; the folder is the
78
95
  # unit callers load. Builders group declarations by the chapter name in
79
96
  # each file, so a folder may hold more than one chapter without a catalog.
80
97
  # Sorting makes source order deterministic while the deferred window keeps
81
98
  # cross-file references from being judged against a partial chapter.
99
+ #
100
+ # @param directory [String] the directory to glob for bluebook chapter files
101
+ # @param patterns [Array<String>] glob patterns, relative to `directory`, selecting the
102
+ # chapter files to load
103
+ # @return [void]
82
104
  def load_bluebooks(directory, patterns = ["*.bluebook"])
83
105
  Bluebook::MetaValidator.defer { load_each(directory, patterns) }
84
106
  Bluebook::MetaValidator.judge_deferred!(Hecks.current_registry)
85
107
  end
86
108
 
87
- # THE EXPLICIT-FILE SIBLING OF `load_domain` — for a caller that names
109
+ # The explicit-file sibling of `load_domain` — for a caller that names
88
110
  # its own exact files rather than a directory to glob (`Loader.boot_files`,
89
111
  # behind `Hecks.boot_files`). No `Dir.glob`, no copying: every path here
90
112
  # is a real file on disk, wherever it actually lives, loaded in place —
@@ -93,7 +115,7 @@ module Hecks
93
115
  # tmpdir (see Loader.boot_files's own header for why that pattern is a
94
116
  # hazard, not a convenience).
95
117
  #
96
- # ORDERED BY CATEGORY, NOT BY THE CALLER'S OWN LIST ORDER — same four
118
+ # Ordered by category, not by the caller's own list order — same four
97
119
  # groups `Vocabulary.fetch("LoadOrder")` walks a directory in
98
120
  # (bluebook chapters, translations, hecksagons, worlds), because a
99
121
  # hecksagon can reference a bluebook's own constants and must not load
@@ -101,6 +123,10 @@ module Hecks
101
123
  # "x.hecksagon", "x.bluebook"` in. Bluebook chapters are judged as one
102
124
  # deferred group exactly like `load_domain` does, for the identical
103
125
  # forward-reference reason (MetaValidator.defer's own header).
126
+ # @param files [Array<String>] file paths to load, relative to `bluebook_directory`
127
+ # @param environment [String, nil] the environment name whose overlay, if present,
128
+ # loads after the selected files
129
+ # @return [void]
104
130
  def load_selected(files, environment: nil)
105
131
  bluebooks, rest = files.partition { |f| f.end_with?(".bluebook") }
106
132
 
@@ -120,6 +146,14 @@ module Hecks
120
146
  load_each(directory, [File.join("environments", "#{environment}.world")])
121
147
  end
122
148
 
149
+ # Loads every file under `directory` matching any of `patterns`, in pattern order.
150
+ #
151
+ # A no-op if `directory` does not exist.
152
+ #
153
+ # @param directory [String] the directory to search
154
+ # @param patterns [Array<String>] glob patterns, relative to `directory`, of files to
155
+ # load with `Kernel.load`
156
+ # @return [void]
123
157
  def load_each(directory, patterns)
124
158
  return unless File.directory?(directory)
125
159
 
@@ -128,6 +162,13 @@ module Hecks
128
162
  end
129
163
  end
130
164
 
165
+ # Resolves a domain path to the directory that actually holds its bluebook files.
166
+ #
167
+ # @param path [String] a domain directory, holding either a `bluebook/` subdirectory or
168
+ # its chapter files directly
169
+ # @return [String] the `bluebook/` subdirectory's absolute path, if one exists; `path`'s
170
+ # own absolute path otherwise
171
+ # @raise [Errno::ENOENT] if neither directory exists
131
172
  def bluebook_directory(path)
132
173
  expanded = File.expand_path(path)
133
174
  nested = File.join(expanded, "bluebook")
@@ -138,11 +179,13 @@ module Hecks
138
179
  raise Errno::ENOENT, "no such domain directory: #{path}"
139
180
  end
140
181
 
141
- # THE DOMAIN YOU ARE STANDING IN. Walks up from `from` — the way git
182
+ # Finds the nearest domain directory at or above `from`, or nil if there is not one.
183
+ #
184
+ # **The domain you are standing in**. Walks up from `from` — the way git
142
185
  # finds `.git` — and answers the nearest directory a boot would accept,
143
186
  # or nil if there is not one above you.
144
187
  #
145
- # MARKED BY A `.hecksagon`, NOT BY A `.bluebook`. Chapters are
188
+ # Marked by a `.hecksagon`, not by a `.bluebook`. Chapters are
146
189
  # everywhere: era translations, the language's own self-hosted grammar,
147
190
  # and `spec/fixtures`, which holds a dozen unrelated ones in a single
148
191
  # directory. A `.hecksagon` is the file that says "this is a domain, and
@@ -152,13 +195,18 @@ module Hecks
152
195
  # Both layouts, because `bluebook_directory` above accepts both: a
153
196
  # domain directory holding a `bluebook/` subdirectory (every example in
154
197
  # this corpus), or one holding the files directly.
155
- # NORMALISED TO THE OUTER DIRECTORY. Standing in `examples/banking/bluebook`,
198
+ # Normalised to the outer directory. Standing in `examples/banking/bluebook`,
156
199
  # the `.hecksagon` is right there, so a plain walk stops on the
157
200
  # `bluebook/` directory itself. Both boot identically — `bluebook_directory`
158
201
  # accepts either and `Loader.boot` takes `File.dirname` of what it gets,
159
202
  # so the registry root comes out the same — but `examples/banking` is the
160
203
  # directory a person names, and the one a `.world`'s `dir "data"` reads
161
204
  # as relative to.
205
+ #
206
+ # @param from [String] the directory to walk up from; defaults to the process's current
207
+ # working directory
208
+ # @return [String, nil] the resolved domain directory's absolute path, or nil if none is
209
+ # found above `from`
162
210
  def domain_root(from = Dir.pwd)
163
211
  found = nearest_domain(File.expand_path(from))
164
212
  return nil unless found
@@ -167,6 +215,12 @@ module Hecks
167
215
  File.basename(found) == "bluebook" && domain?(parent) ? parent : found
168
216
  end
169
217
 
218
+ # Walks up from `current` until it finds a domain directory, or reaches the filesystem
219
+ # root.
220
+ #
221
+ # @param current [String] the absolute directory path to start searching from
222
+ # @return [String, nil] the nearest directory (`current` or an ancestor) that `domain?`
223
+ # accepts, or nil if none is found before the filesystem root
170
224
  def nearest_domain(current)
171
225
  loop do
172
226
  return current if domain?(current)
@@ -178,11 +232,25 @@ module Hecks
178
232
  end
179
233
  end
180
234
 
235
+ # Reports whether `directory` is a domain root.
236
+ #
237
+ # @param directory [String] the directory to check
238
+ # @return [Boolean] true if `directory`, or its `bluebook/` subdirectory, holds a
239
+ # `.hecksagon` file
181
240
  def domain?(directory)
182
241
  !Dir[File.join(directory, "*.hecksagon")].empty? ||
183
242
  !Dir[File.join(directory, "bluebook", "*.hecksagon")].empty?
184
243
  end
185
244
 
245
+ # Resolves the shared root a domain's own ports/adapters overlay from.
246
+ #
247
+ # @param given [String, nil] an explicit shared-root override; returned expanded as-is
248
+ # when present
249
+ # @param directory [String] the domain directory to search upward from when `given` is
250
+ # nil
251
+ # @return [String, nil] `given`'s expanded path, or the nearest ancestor of `directory`
252
+ # holding a `ports` or `adapters` folder; nil if none is found before the filesystem
253
+ # root
186
254
  def shared_root(given, directory)
187
255
  return File.expand_path(given) if given
188
256
 
@@ -198,6 +266,11 @@ module Hecks
198
266
  end
199
267
  end
200
268
 
269
+ # Resolves the framework's own bundled `folder` directory under `lib/hecks`.
270
+ #
271
+ # @param folder [String] `"ports"` or `"adapters"`, the framework's own bundled
272
+ # directory name
273
+ # @return [String] the absolute path to `lib/hecks/<folder>`
201
274
  def library(folder)
202
275
  File.expand_path("../../#{folder}", __dir__)
203
276
  end
@@ -4,26 +4,26 @@ require_relative "../../ports/authentication"
4
4
 
5
5
  module Hecks
6
6
  module Adapters
7
- # GOOGLE'S OWN OIDC HANDSHAKE — the `authentication` port's one real
8
- # implementation today, moved here from being hand-rolled per-app
9
- # (an embryonaut_console `google_auth.rb` used to do exactly this;
10
- # any hecks-based app gets Google sign-in for free now, the
7
+ # **Google's own OIDC handshake** — the `authentication` port's one real
8
+ # implementation today. Consolidates what was hand-rolled per-app
9
+ # (an embryonaut_console `google_auth.rb` did this on its own) into
10
+ # one adapter, so any hecks-based app gets Google sign-in for free, the
11
11
  # same "one adapter, reusable everywhere" value every other adapter
12
- # in this directory already has).
12
+ # in this directory already has.
13
13
  #
14
- # `oauth2` does ONLY the authorization-code exchange (no Rack
14
+ # `oauth2` does only the authorization-code exchange (no Rack
15
15
  # middleware, no Omniauth strategy indirection) ; `google-id-token`
16
- # does ONLY ID-token verification (signature checked against
16
+ # does only ID-token verification (signature checked against
17
17
  # Google's real, rotating JWKS — real, maintained code, never
18
18
  # hand-rolled here). Neither library decides what a verified
19
- # (issuer, subject) MEANS — that's `Ports::IdentityResolution`'s
19
+ # (issuer, subject) means — that's `Ports::IdentityResolution`'s
20
20
  # job, called by whoever consumes this port's `verify`.
21
21
  #
22
- # LAZY REQUIRES, same reasoning `Postgres.connect_for` already
22
+ # Lazy requires, same reasoning `Postgres.connect_for` already
23
23
  # holds itself to for `pg`: a domain that never binds
24
24
  # `authentication` to this adapter should never need these gems
25
- # installed. This FILE loads in every boot (driven.rb's own
26
- # unconditional require_relative list) ; the GEMS load only where
25
+ # installed. This file loads in every boot (driven.rb's own
26
+ # unconditional require_relative list) ; the gems load only where
27
27
  # a real handshake actually happens.
28
28
  #
29
29
  # `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/`GOOGLE_REDIRECT_URI` —
@@ -34,6 +34,10 @@ module Hecks
34
34
 
35
35
  module_function
36
36
 
37
+ # Builds the OAuth2 client for Google's token endpoints.
38
+ #
39
+ # @return [OAuth2::Client] a client configured for Google's OAuth2/OIDC endpoints
40
+ # @raise [KeyError] if `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is unset
37
41
  def client
38
42
  require "oauth2"
39
43
  OAuth2::Client.new(
@@ -47,6 +51,11 @@ module Hecks
47
51
  # The URL to send a browser to, carrying a fresh CSRF `state` the
48
52
  # caller is responsible for stashing (a session, typically) and
49
53
  # checking again in `verify`.
54
+ #
55
+ # @return [Array(String, String)] the URL to send the browser to, and the fresh CSRF
56
+ # `state` embedded in it
57
+ # @raise [KeyError] if `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` or
58
+ # `GOOGLE_REDIRECT_URI` is unset
50
59
  def authorization_url
51
60
  state = SecureRandom.hex(24)
52
61
  url = client.auth_code.authorize_url(
@@ -67,8 +76,20 @@ module Hecks
67
76
  # the raw token, never anything a caller would need to re-verify
68
77
  # itself. `email_verified` rides along because a caller granting
69
78
  # access off this email needs to know Google actually checked it.
79
+ #
80
+ # @param code [String] the authorization code the provider sent back
81
+ # @param state [String, nil] the state parameter the provider returned; nil is refused
82
+ # @param expected_state [String, nil] the state `authorization_url` handed out before
83
+ # the redirect; nil is refused
84
+ # @return [Hash{Symbol => Object}] `issuer:` and `subject:` (String), `email:` (String,
85
+ # or nil if the token carries none), `email_verified:` (Boolean)
86
+ # @raise [Ports::Authentication::ValidationError] if either state is nil or the two
87
+ # differ, the code exchange fails, the response has no ID token, or the ID token does
88
+ # not verify
89
+ # @raise [KeyError] if `GOOGLE_CLIENT_ID` or `GOOGLE_REDIRECT_URI` is unset, or a
90
+ # verified token lacks an `iss` or `sub` claim
70
91
  def verify(code:, state:, expected_state:)
71
- # BOTH GEMS, BEFORE ANYTHING ELSE — not staggered further down
92
+ # **Both gems, before anything else** — not staggered further down
72
93
  # this method: the rescue clause below names GoogleIDToken
73
94
  # ::ValidationError, and Ruby resolves that constant reference
74
95
  # at the moment an exception is being matched, not at parse
@@ -2,26 +2,26 @@ require "time"
2
2
 
3
3
  module Hecks
4
4
  module Adapters
5
- # THE `authorization` PORT, FULFILLED BY GOVERNANCE — same registry,
5
+ # **The `authorization` port, fulfilled by governance** — same registry,
6
6
  # same boot, so "ask Governance" is a dispatch against records
7
7
  # already sitting in the store this adapter is handed, not a bridge
8
8
  # to a second runtime. `Runtime::Dispatcher.new(registry)` is cheap
9
9
  # to build fresh per call (`Registry#capability_graph`'s own
10
10
  # neighbor, `#repository`, does the same kind of on-demand build) —
11
11
  # nothing here holds one across calls, so there is no boot-order
12
- # dependency on WHEN Governance's bluebook loads relative to this
12
+ # dependency on when Governance's bluebook loads relative to this
13
13
  # adapter, only that it has by the time `holds_role?` is called.
14
14
  #
15
- # AN ACTIVE ASSIGNMENT, not merely a historical one : `RoleAssignment`
16
- # answers with every assignment an actor has ever held
17
- # (`AssignmentsForActor`'s own description — "currently or
18
- # historically") and leaves `ends_at` for the caller to read, the
19
- # same deferral `Governance::RoleTransition.Allowed` makes for the
20
- # same reason. This is that caller.
15
+ # An active assignment, not merely a past one — `RoleAssignment`
16
+ # answers with every assignment an actor has ever held, current or
17
+ # past (`AssignmentsForActor`'s own description covers both), and
18
+ # leaves `ends_at` for the caller to read, the same deferral
19
+ # `Governance::RoleTransition.Allowed` makes for the same reason.
20
+ # This is that caller.
21
21
  module GovernanceAuthorization
22
22
  module_function
23
23
 
24
- # `as_of` and `scope` are BOTH optional, same opt-in shape
24
+ # `as_of` and `scope` are both optional, same opt-in shape
25
25
  # `refuse_role_mismatch` already gives `actor_id` itself — an
26
26
  # unbound `as_of` skips the `starts_at` check and an unbound
27
27
  # `scope` skips the `scope` check, exactly the behavior before
@@ -29,9 +29,21 @@ module Hecks
29
29
  # resolved from `Ports::Clock.now`, called by the caller at the
30
30
  # door, never by this adapter — see `Ports::Clock`'s own header
31
31
  # for why the dispatch path must not consult the clock itself.
32
+ #
33
+ # @param registry [Runtime::Registry] the booted registry, to resolve the authorization
34
+ # provider's verb
35
+ # @param actor_id [String] the actor whose grants are checked, compared as a String
36
+ # @param role [String, Symbol] the role name to look for, compared as a String
37
+ # @param as_of [Integer, nil] Unix epoch seconds; a grant whose `starts_at` is later, or
38
+ # does not parse as a time, does not count. nil skips the `starts_at` check
39
+ # @param scope [String, nil] the scope the caller acts in; nil skips the scope check
40
+ # @return [Boolean] true if at least one live grant of `role` to `actor_id` passes the
41
+ # `as_of` and `scope` checks
42
+ # @raise [Runtime::WiringError] if the loaded chapters providing `"authorization"` are
43
+ # not exactly one (see `provided_verb`)
32
44
  def holds_role?(registry, actor_id:, role:, as_of: nil, scope: nil)
33
45
  rows = Runtime::Dispatcher.new(registry).query(
34
- "Governance::RoleAssignment.AssignmentsForActor",
46
+ provided_verb(registry, :assignments),
35
47
  actor_id: { value: actor_id.to_s }
36
48
  )
37
49
 
@@ -43,22 +55,33 @@ module Hecks
43
55
  end
44
56
  end
45
57
 
46
- # `scope` UNCHECKED WHEN NOT STATED, same as every other opt-in
58
+ # `scope` unchecked when not stated, same as every other opt-in
47
59
  # field here — a caller that never says which scope it is acting
48
60
  # in gets the pre-scope behavior: any live assignment for the role
49
61
  # authorizes, everywhere. A caller that does state one only
50
- # authorizes against an assignment granted for THAT scope.
62
+ # authorizes against an assignment granted for that scope.
63
+ #
64
+ # @param row [Hash{Symbol => Object}] one `RoleAssignment` row as
65
+ # `Runtime::Dispatcher#query` returns it; `:scope` holds a `{value: String}` Hash
66
+ # @param scope [String, nil] the scope to check against; nil accepts any scope
67
+ # @return [Boolean] true if `scope` is nil, or the row's own scope matches it
51
68
  def in_scope?(row, scope)
52
69
  scope.nil? || row[:scope][:value] == scope.to_s
53
70
  end
54
71
 
55
- # `starts_at` IS A FREE-TEXT STRING in the bluebook (`Timestamp`'s
72
+ # `starts_at` is a free-text string in the bluebook (`Timestamp`'s
56
73
  # only invariant is "present", not any particular format) — parsed
57
74
  # here with `Time.parse` rather than compared lexically, since
58
75
  # nothing guarantees every caller writes it zero-padded ISO 8601.
59
- # FAILS CLOSED : a `starts_at` that does not parse is treated as
76
+ # Fails closed : a `starts_at` that does not parse is treated as
60
77
  # not-yet-started rather than silently ignored, the same direction
61
78
  # every other check in this method already fails.
79
+ #
80
+ # @param row [Hash{Symbol => Object}] one `RoleAssignment` row as
81
+ # `Runtime::Dispatcher#query` returns it; `:starts_at` holds a `{value: String}` Hash
82
+ # @param as_of [Integer, nil] Unix epoch seconds to compare against; nil accepts any row
83
+ # @return [Boolean] true if `as_of` is nil, or the row's `starts_at` parses and is at or
84
+ # before `as_of`; false if `starts_at` does not parse as a time
62
85
  def started?(row, as_of)
63
86
  return true if as_of.nil?
64
87
 
@@ -67,35 +90,81 @@ module Hecks
67
90
  false
68
91
  end
69
92
 
70
- # THE OTHER HALF — may role X act as role Y. `RoleTransition.Allowed`
93
+ # Answers whether one role may act as another.
94
+ #
95
+ # **The other half** — may role X act as role Y. `RoleTransition.Allowed`
71
96
  # is identified by the exact pair, so at most one row ever comes
72
97
  # back ; still read as `.any?` rather than trusting that structurally,
73
98
  # the same defensiveness `holds_role?` already has to have anyway
74
99
  # since `AssignmentsForActor` can return several.
100
+ #
101
+ # @param registry [Runtime::Registry] the booted registry, to resolve the authorization
102
+ # provider's verb
103
+ # @param from_role [String, Symbol] the role the caller holds, compared as a String
104
+ # @param to_role [String, Symbol] the role the caller wants to act as, compared as a
105
+ # String
106
+ # @return [Boolean] true if a live (not ended) allowance lets `from_role` act as
107
+ # `to_role`
108
+ # @raise [Runtime::WiringError] if the loaded chapters providing `"authorization"` are
109
+ # not exactly one (see `provided_verb`)
75
110
  def authorized_as?(registry, from_role:, to_role:)
76
111
  rows = Runtime::Dispatcher.new(registry).query(
77
- "Governance::RoleTransition.Allowed",
112
+ provided_verb(registry, :transitions),
78
113
  from_role: { value: from_role.to_s }, to_role: { value: to_role.to_s }
79
114
  )
80
115
 
81
116
  rows.any? { |row| row[:ends_at].nil? }
82
117
  end
83
118
 
84
- # THE ROLE ITSELF, not just a yes/no about one — the same
119
+ # The role itself, not just a yes/no about one — the same
85
120
  # `AssignmentsForActor` query `holds_role?` runs, just returning
86
121
  # the live (non-revoked) row's `role_name` instead of comparing it
87
122
  # against a caller-supplied guess. `nil` for no live assignment at
88
123
  # all — the caller's own fallback (an aggregate's own role field,
89
124
  # a default) is domain-specific and does not belong here.
125
+ #
126
+ # @param registry [Runtime::Registry] the booted registry, to resolve the authorization
127
+ # provider's verb
128
+ # @param actor_id [String] the actor to look up, compared as a String
129
+ # @return [String, nil] the role name of the actor's first live (not ended) grant, or
130
+ # nil if it has none
131
+ # @raise [Runtime::WiringError] if the loaded chapters providing `"authorization"` are
132
+ # not exactly one (see `provided_verb`)
90
133
  def live_role_for(registry, actor_id:)
91
134
  rows = Runtime::Dispatcher.new(registry).query(
92
- "Governance::RoleAssignment.AssignmentsForActor",
135
+ provided_verb(registry, :assignments),
93
136
  actor_id: { value: actor_id.to_s }
94
137
  )
95
138
 
96
139
  live = rows.find { |row| row[:ends_at].nil? }
97
140
  live && live[:role_name][:value]
98
141
  end
142
+
143
+ # The verb, read from the provider's own declaration — `provides
144
+ # "authorization", assignments: ..., transitions: ...` on whichever
145
+ # loaded chapter declares it (Governance's, in every boot today).
146
+ # Exactly one provider, the same "the runtime will not choose for
147
+ # you" rule `Ports::Authorization.adapter` applies to adapters.
148
+ #
149
+ # @param registry [Runtime::Registry] the booted registry to search for the
150
+ # `"authorization"` provider
151
+ # @param key [Symbol] which declared verb to read — `:assignments`, `:grant`, or
152
+ # `:transitions`
153
+ # @return [String] the fully-qualified verb the loaded chapter provides for `key`
154
+ # @raise [Runtime::WiringError] if the loaded chapters providing `"authorization"` are
155
+ # not exactly one
156
+ def provided_verb(registry, key)
157
+ providers = registry.authorization_providers
158
+ unless providers.size == 1
159
+ raise Runtime::WiringError,
160
+ "#{providers.size} loaded chapters provide \"authorization\"" \
161
+ "#{" (#{providers.map(&:name).sort.join(', ')})" unless providers.empty?} — " \
162
+ "a role lookup needs exactly one (framework members declaring it: " \
163
+ "#{Framework.providers_of(Bluebook::Capabilities::AUTHORIZATION).join(', ')})"
164
+ end
165
+
166
+ providers.first.provided_verb(Bluebook::Capabilities::AUTHORIZATION, key)
167
+ end
99
168
  end
100
169
  end
101
170
  end
@@ -6,18 +6,23 @@ module Hecks
6
6
  # The append-only journal beside the snapshot: one JSON line per
7
7
  # entry, fsynced on append, replayed over the snapshot on read.
8
8
  module Journal
9
+ # Reads the whole journal back in append order, for `AppendOnly#recover!` to replay.
10
+ #
11
+ # @return [Array<Ports::Persistence::Entry>] every journalled entry, state decoded
12
+ # through the state codec; `[]` when the journal file does not exist or is empty
13
+ # @raise [Malformed] if a journal line is not valid JSON
9
14
  def entries
10
15
  return [] unless File.exist?(@journal_path)
11
16
 
12
17
  File.readlines(@journal_path, chomp: true).reject(&:empty?).map do |line|
13
18
  value = JSON.parse(line)
14
- state = value["state"]&.transform_keys(&:to_sym)
19
+ state = Ports::Persistence::StateCodec.decode(@aggregate, value["state"])
15
20
  Ports::Persistence::Entry.new(operation: value.fetch("operation"), id: value.fetch("id"), state: state,
16
21
  mirrors: value["mirrors"])
17
22
  end
18
23
  end
19
24
 
20
- # An EXPLICIT, opt-in maintenance operation — never run
25
+ # An explicit, opt-in maintenance operation — never run
21
26
  # automatically after an ordinary save/delete. Heki's journal is
22
27
  # not a disposable write-ahead log: it is this adapter's own
23
28
  # answer to `entries`, and `entries` is a real port contract
@@ -26,7 +31,7 @@ module Hecks
26
31
  # and `Registry#projection_current?` to catch a projection up to
27
32
  # its authoritative source, and by `bin/history` to show "every
28
33
  # journal entry a domain's append-only adapters hold" — the same
29
- # contract Postgres/Sqlite/D1 uphold by way of a journal TABLE
34
+ # contract Postgres/Sqlite/D1 uphold by way of a journal table
30
35
  # that is never pruned. A real example (`examples/banking`,
31
36
  # `persisted_by("Heki")` + `projected_by("SqliteProjection")`)
32
37
  # depends on this today. Compacting throws that full history away
@@ -46,6 +51,9 @@ module Hecks
46
51
  # once. A crash before `write` completes leaves the journal
47
52
  # fully intact and the prior snapshot untouched, exactly today's
48
53
  # existing crash-recovery guarantee.
54
+ #
55
+ # @return [void]
56
+ # @raise [Malformed] if the snapshot or journal file is corrupt
49
57
  def compact!
50
58
  with_lock do
51
59
  current = replay_journal(read_snapshot)
@@ -95,7 +103,8 @@ module Hecks
95
103
  end
96
104
 
97
105
  def append_entry(operation, id, state)
98
- line = "#{JSON.generate(operation: operation, id: id.to_s, state: state, mirrors: @entry_mirrors)}\n"
106
+ encoded = Ports::Persistence::StateCodec.encode(@aggregate, state)
107
+ line = "#{JSON.generate(operation: operation, id: id.to_s, state: encoded, mirrors: @entry_mirrors)}\n"
99
108
 
100
109
  # One write, not JSON-then-newline as two: two concurrent
101
110
  # appends can only interleave *between* writes, never inside
@@ -4,31 +4,41 @@ require_relative "journal"
4
4
  module Hecks
5
5
  module Adapters
6
6
  class Heki
7
- # THE OPTIONAL saga-persistence capability (§2), Heki's own shape —
8
- # a SIBLING snapshot+journal file pair, built the exact same way an
9
- # aggregate's own persistence already is: `Snapshot`/`Journal`
10
- # (heki/snapshot.rb, heki/journal.rb) operate generically on
11
- # `@path`/`@journal_path`/`@entry_mirrors` and never touch
12
- # `@aggregate`, so this reuses them unchanged rather than
13
- # re-deriving the same binary framing and crash-recovery replay.
7
+ # The optional saga-persistence capability (§2)'s Heki-specific store — a sibling
8
+ # snapshot+journal file pair, keyed by (domain, process manager, correlation).
9
+ #
10
+ # ## Why a sibling file pair, not a table
11
+ #
12
+ # Built the exact same way an aggregate's own persistence already
13
+ # is: `Snapshot`/`Journal` (heki/snapshot.rb, heki/journal.rb)
14
+ # operate generically on `@path`/`@journal_path`/`@entry_mirrors`
15
+ # and never touch `@aggregate`, so this reuses them unchanged
16
+ # rather than re-deriving the same binary framing and
17
+ # crash-recovery replay.
18
+ #
19
+ # ## Where the file lives
14
20
  #
15
21
  # Reserved file name (`hecks_saga_instances.heki`, matching the
16
22
  # `hecks_`-prefix convention every other new saga table in this
17
23
  # work uses) avoids colliding with any real aggregate's own
18
- # `storage_name`. Lives in the SAME directory an aggregate's own
24
+ # `storage_name`. Lives in the same directory an aggregate's own
19
25
  # `.heki` file would (`File.dirname(@path)`, `Heki`'s own call
20
26
  # below) — which, since Heki's `resolve_path` has no per-domain
21
- # component at all, is typically shared across EVERY domain
27
+ # component at all, is typically shared across every domain
22
28
  # booted from the same `root`. `domain` is therefore carried
23
29
  # inside each record and filtered on read, the same reason
24
30
  # Postgres's own `hecks_saga_instances` keeps an explicit `domain`
25
31
  # column under schema isolation (§3).
26
32
  #
27
- # ONE flat records hash, keyed by a composite string (Heki's own
33
+ # ## Record shape
34
+ #
35
+ # One flat records hash, keyed by a composite string (Heki's own
28
36
  # snapshot format is id-keyed, not tuple-keyed) — never exposed
29
37
  # outside this class; `each_saga` yields the five real fields a
30
38
  # caller actually wants, not the internal key shape.
31
39
  #
40
+ # ## Locking
41
+ #
32
42
  # Locked the same way an aggregate's own store is: `with_lock`
33
43
  # (`Snapshot`, shared) serializes each save/delete's read-modify-
34
44
  # write against `@path`'s own lock file — a saga gets exactly the
@@ -38,12 +48,27 @@ module Hecks
38
48
  include Snapshot
39
49
  include Journal
40
50
 
51
+ # @param dir [String] the directory to hold the saga snapshot+journal file pair,
52
+ # shared with the aggregate `.heki` files that already live there
41
53
  def initialize(dir)
42
54
  @path = File.join(dir, "hecks_saga_instances.heki")
43
55
  @journal_path = "#{@path}.journal"
44
56
  @entry_mirrors = nil
45
57
  end
46
58
 
59
+ # Upserts one saga instance's checkpoint, keyed by domain, process manager and
60
+ # correlation, under the file lock.
61
+ #
62
+ # @param domain [String] the owning domain, carried in the record and filtered on
63
+ # read
64
+ # @param process_manager [String] the process manager's name
65
+ # @param correlation [String] the instance's correlation value
66
+ # @param state [String] the saga's current state name
67
+ # @param memory [Hash] the saga's working memory to persist
68
+ # @param completed_compensations [Array] the ledger of completed compensable legs;
69
+ # `[]` when none
70
+ # @return [Hash{String => Hash}] the store's full internal records Hash after the
71
+ # write; callers ignore it
47
72
  def save_saga(domain, process_manager, correlation, state, memory, completed_compensations = [])
48
73
  key = key_for(domain, process_manager, correlation)
49
74
  record = { "domain" => domain, "process_manager" => process_manager,
@@ -59,6 +84,14 @@ module Hecks
59
84
  end
60
85
  end
61
86
 
87
+ # Removes a finished saga instance's checkpoint, if present, under the file lock; a
88
+ # missing one is not an error.
89
+ #
90
+ # @param domain [String] the owning domain
91
+ # @param process_manager [String] the process manager's name
92
+ # @param correlation [String] the instance's correlation value
93
+ # @return [Hash{String => Hash}] the store's full internal records Hash after the
94
+ # delete; callers ignore it
62
95
  def delete_saga(domain, process_manager, correlation)
63
96
  key = key_for(domain, process_manager, correlation)
64
97
 
@@ -71,6 +104,19 @@ module Hecks
71
104
  end
72
105
  end
73
106
 
107
+ # Yields every checkpointed saga instance of `domain`, for `Registry
108
+ # #rehydrate_sagas!` to restore at boot.
109
+ #
110
+ # @param domain [String] the owning domain to filter records to
111
+ # @yieldparam process_manager [String] the process manager's name
112
+ # @yieldparam correlation [String] the instance's correlation value
113
+ # @yieldparam state [String] the saga's state name
114
+ # @yieldparam memory [Hash{Symbol => Object}] the saga's memory, Symbol keys at every
115
+ # depth
116
+ # @yieldparam completed_compensations [Array] the completed-compensation ledger, `[]`
117
+ # when the record carries none
118
+ # @return [Enumerator, Hash{String => Hash}] an enumerator over the same five values
119
+ # when no block is given; otherwise the store's full internal records Hash
74
120
  def each_saga(domain)
75
121
  return enum_for(:each_saga, domain) unless block_given?
76
122