hecks 1.0.2 → 1.2.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 (247) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
@@ -14,8 +14,8 @@ module Hecks
14
14
  # correlation key even IS is representation-dependent (the object
15
15
  # itself? its serialised text?). `:"end_to_end.value"` reads the one
16
16
  # field with a single unambiguous rendering.
17
- def saga_correlation(pm, event)
18
- path = pm.correlates_by.to_s.split(".")
17
+ def saga_correlation(process_manager, event)
18
+ path = process_manager.correlates_by.to_s.split(".")
19
19
  # A LATER EVENT MAY ALREADY HOLD THE SCALAR. `reference.value` digs a
20
20
  # value object's field out of a FRESH declaration (TransferRequested's
21
21
  # `reference` IS a TransferReference) — but a downstream event this
@@ -45,7 +45,7 @@ module Hecks
45
45
  # Keyed by `correlation_head`
46
46
  # rather than a bare scalar so an event stamped by one saga cannot be
47
47
  # misread by an unrelated one correlating on a different field.
48
- stamped = event.correlation && event.correlation[pm.correlation_head.to_s]
48
+ stamped = event.correlation && event.correlation[process_manager.correlation_head.to_s]
49
49
  return stamped unless stamped.nil? || stamped.to_s.empty?
50
50
 
51
51
  # A SELF-REFERENCING LEG carries the correlation forward under its
@@ -69,7 +69,7 @@ module Hecks
69
69
  # not by which dispatch convention the caller happened to use.
70
70
  #
71
71
  # GATED, still — a manually-dispatched command on a wholly
72
- # UNRELATED aggregate can share an event NAME this pm happens to
72
+ # UNRELATED aggregate can share an event NAME this process_manager happens to
73
73
  # handle (`Drawer.Take` also emits "Taken", the same name a
74
74
  # SAGA-DISPATCHED leg uses) with nothing this saga should read as
75
75
  # its own conversation. What makes a leg genuinely
@@ -79,17 +79,17 @@ module Hecks
79
79
  # name: `OnboardingCase.identity_heads` really does include
80
80
  # `:reference`, `correlates_by :"reference.value"`'s own head ;
81
81
  # `Drawer.identity_heads` is `[:number]`, nowhere close.
82
- self_identified?(pm, event) ? event.id : nil
82
+ self_identified?(process_manager, event) ? event.id : nil
83
83
  end
84
84
 
85
- def self_identified?(pm, event)
85
+ def self_identified?(process_manager, event)
86
86
  domain, bare_name = event.aggregate.to_s.split("::", 2)
87
87
  return false unless bare_name
88
88
 
89
89
  construct = @registry.bluebook(domain)&.aggregate(bare_name)
90
90
  return false unless construct
91
91
 
92
- construct.identity_heads.map(&:to_s).include?(pm.correlation_head.to_s)
92
+ construct.identity_heads.map(&:to_s).include?(process_manager.correlation_head.to_s)
93
93
  end
94
94
  end
95
95
  end
@@ -8,6 +8,11 @@ require_relative "saga_pending_dispatch"
8
8
 
9
9
  module Hecks
10
10
  module Runtime
11
+ # Runs one domain's `process_manager` (saga) declarations against a
12
+ # just-emitted event: begins a new correlated instance on its
13
+ # starts_on event, advances a matching handler's state and dispatches
14
+ # its declared commands, and ends/deletes the instance on ends_on —
15
+ # checkpointing each transition durably before its dispatches run.
11
16
  class SagaInterpreter
12
17
  include Correlation
13
18
 
@@ -28,14 +33,17 @@ module Hecks
28
33
  @door = door
29
34
  end
30
35
 
31
- def advance(event, domain)
36
+ # `only:` — one process manager, the outbox relay's way of running
37
+ # exactly the consumer a row names (`Runtime::Outbox::Relay#
38
+ # run_consumer`); nil advances every manager the domain declares.
39
+ def advance(event, domain, only: nil)
32
40
  bluebook = @registry.bluebook(domain)
33
41
  return unless bluebook
34
42
 
35
- bluebook.process_managers.each do |pm|
36
- begin_saga(pm, event, domain)
37
- advance_saga(pm, event, domain)
38
- end_saga(pm, event, domain)
43
+ (only ? [only] : bluebook.process_managers).each do |process_manager|
44
+ begin_saga(process_manager, event, domain)
45
+ advance_saga(process_manager, event, domain)
46
+ end_saga(process_manager, event, domain)
39
47
  end
40
48
  end
41
49
 
@@ -63,11 +71,11 @@ module Hecks
63
71
  # what it always did. The marker exists ONLY in the persisted
64
72
  # blob, and only for as long as a dispatch cascade is genuinely
65
73
  # in flight for this instance.
66
- def checkpoint(pm, correlation, instance, domain, pending: nil)
74
+ def checkpoint(process_manager, correlation, instance, domain, pending: nil)
67
75
  memory = deep_copy(instance[:memory])
68
76
  memory[SAGA_PENDING_DISPATCH_KEY] = pending if pending
69
77
  @registry.saga_persistence(domain).save_saga(
70
- process_manager: pm.name, correlation: correlation,
78
+ process_manager: process_manager.name, correlation: correlation,
71
79
  state: instance[:state], memory: memory,
72
80
  completed_compensations: deep_copy_array(instance[:completed_compensations])
73
81
  )
@@ -84,18 +92,18 @@ module Hecks
84
92
 
85
93
  def deep_copy(hash) = JSON.parse(JSON.generate(hash), symbolize_names: true)
86
94
 
87
- def begin_saga(pm, event, domain)
88
- return unless event.name == pm.starts_on
95
+ def begin_saga(process_manager, event, domain)
96
+ return unless event.name == process_manager.starts_on
89
97
 
90
- correlation = saga_correlation(pm, event)
98
+ correlation = saga_correlation(process_manager, event)
91
99
  if correlation.to_s.empty?
92
- @registry.saga_log << { process_manager: pm.name, on: event.name,
93
- born: false, reason: "no #{pm.correlates_by} in the payload" }
100
+ @registry.saga_log << { process_manager: process_manager.name, on: event.name,
101
+ born: false, reason: "no #{process_manager.correlates_by} in the payload" }
94
102
  return
95
103
  end
96
104
 
97
105
  created = @registry.saga_mutex.synchronize do
98
- next false if @registry.saga_instances[pm.name].key?(correlation)
106
+ next false if @registry.saga_instances[process_manager.name].key?(correlation)
99
107
 
100
108
  # `.dup`, NOT THE SAME OBJECT — a fresh saga's own memory starts
101
109
  # as a COPY of the starting event's own payload, never the
@@ -113,15 +121,15 @@ module Hecks
113
121
  # still matters: it is what makes the saga's own memory a
114
122
  # normal, writable Hash of its own, rather than one write away
115
123
  # from crashing every future in-place `remember`.
116
- instance = { state: pm.states.first, memory: event.payload.dup, completed_compensations: [] }
117
- @registry.saga_instances[pm.name][correlation] = instance
118
- checkpoint(pm, correlation, instance, domain)
124
+ instance = { state: process_manager.states.first, memory: event.payload.dup, completed_compensations: [] }
125
+ @registry.saga_instances[process_manager.name][correlation] = instance
126
+ checkpoint(process_manager, correlation, instance, domain)
119
127
  true
120
128
  end
121
129
  return unless created
122
130
 
123
- @registry.saga_log << { process_manager: pm.name, on: event.name,
124
- instance: correlation, born: true, state: pm.states.first }
131
+ @registry.saga_log << { process_manager: process_manager.name, on: event.name,
132
+ instance: correlation, born: true, state: process_manager.states.first }
125
133
  end
126
134
 
127
135
  # THE MUTEX COVERS ONLY THE STATE-CHECK-AND-MUTATE-AND-CHECKPOINT
@@ -131,38 +139,50 @@ module Hecks
131
139
  # or itself again) on the SAME thread, and `Mutex` is not
132
140
  # reentrant: holding it across that call would deadlock the
133
141
  # thread against itself the moment any real chain did that.
134
- def advance_saga(pm, event, domain)
135
- handler = pm.handler_for(event.name)
136
- return unless handler
142
+ def advance_saga(process_manager, event, domain)
143
+ return unless process_manager.handles?(event.name)
137
144
 
138
- correlation = saga_correlation(pm, event)
145
+ correlation = saga_correlation(process_manager, event)
139
146
  return if correlation.to_s.empty?
140
147
 
141
- record = { process_manager: pm.name, on: event.name, instance: correlation }
148
+ record = { process_manager: process_manager.name, on: event.name, instance: correlation }
142
149
  instance = nil
150
+ handler = nil
143
151
  pre_state = nil
144
152
 
145
153
  advanced = @registry.saga_mutex.synchronize do
146
- instance = @registry.saga_instances[pm.name][correlation]
154
+ instance = @registry.saga_instances[process_manager.name][correlation]
147
155
  unless instance
148
156
  @registry.saga_log << record.merge(advanced: false, reason: "no conversation remembers #{correlation.inspect}")
149
157
  next false
150
158
  end
151
- unless instance[:state] == handler.from_state
159
+ # THE LEG IS CHOSEN BY (EVENT, CURRENT STATE) — C10.3. Read
160
+ # under the mutex, against the state this instance holds right
161
+ # now, so two legs on the same event from different states each
162
+ # answer exactly when their own state is current.
163
+ handler = process_manager.handler_for(event.name, instance[:state])
164
+ unless handler
152
165
  @registry.saga_log << record.merge(advanced: false,
153
- reason: "in #{instance[:state].inspect}, not #{handler.from_state.inspect}")
166
+ reason: leg_mismatch(process_manager, event.name, instance[:state]))
154
167
  next false
155
168
  end
156
169
 
157
170
  pre_state = instance[:state]
158
171
  instance[:state] = handler.to_state
159
- checkpoint(pm, correlation, instance, domain,
172
+ checkpoint(process_manager, correlation, instance, domain,
160
173
  pending: pending_marker(event, handler, pre_state, instance[:state]))
161
174
  true
162
175
  end
163
176
  return unless advanced
164
177
 
165
- settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state)
178
+ settle_transition(process_manager, event, handler, instance, correlation, domain, record, pre_state)
179
+ end
180
+
181
+ # "in X, not Y" — the same wording as before C10.3 when one leg
182
+ # answers the event; every leg's own from: listed when several do.
183
+ def leg_mismatch(process_manager, event_name, state)
184
+ expected = process_manager.handlers_for(event_name).map { |h| h.from_state.inspect }.uniq
185
+ "in #{state.inspect}, not #{expected.join(' or ')}"
166
186
  end
167
187
 
168
188
  def pending_marker(event, handler, from_state, to_state)
@@ -175,13 +195,14 @@ module Hecks
175
195
  # instance at all", `unwind`'s doesn't), then this: log the real
176
196
  # observed transition, run the leg's dispatches, and clear the
177
197
  # pending marker once that cascade — however it ended — is done.
178
- def settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state, drain_compensations: false)
198
+ def settle_transition(process_manager, event, handler, instance, correlation, domain, record, pre_state,
199
+ drain_compensations: false)
179
200
  # `from:`/`to:` are the INSTANCE'S OWN real pre/post state — read
180
201
  # back from `instance` itself, never re-derived from `handler.
181
202
  # from_state`/`handler.to_state` a second time. `Properties.saga_
182
203
  # advances_follow_declared_handlers` (fuzzing/properties.rb) builds
183
204
  # its OWN "declared edges" list from this SAME handler object (via
184
- # `pm.handlers`), so a log entry that just echoed `handler.
205
+ # `process_manager.handlers`), so a log entry that just echoed `handler.
185
206
  # from_state`/`handler.to_state` back could never disagree with
186
207
  # that list no matter what the runtime actually did — the entry
187
208
  # and the thing it's checked against would be the identical fact,
@@ -205,12 +226,12 @@ module Hecks
205
226
  # construction too, not only by the state guard.
206
227
  if drain_compensations
207
228
  compensations = instance[:completed_compensations] || []
208
- deliver_derived_compensation(pm, compensations.pop, correlation, domain) until compensations.empty?
209
- checkpoint(pm, correlation, instance, domain)
229
+ deliver_derived_compensation(process_manager, compensations.pop, correlation, domain) until compensations.empty?
230
+ checkpoint(process_manager, correlation, instance, domain)
210
231
  end
211
232
 
212
233
  handler.dispatches.each do |spec|
213
- deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
234
+ deliver_saga_dispatch(process_manager, spec, event, instance, correlation, domain)
214
235
  end
215
236
 
216
237
  # THE CLEAR — guarded by the SAME identity check `end_saga`'s own
@@ -231,15 +252,17 @@ module Hecks
231
252
  # now, so this is not the reentrancy hazard `advance_saga`'s own
232
253
  # comment warns about.
233
254
  @registry.saga_mutex.synchronize do
234
- next unless @registry.saga_instances[pm.name][correlation].equal?(instance)
255
+ next unless @registry.saga_instances[process_manager.name][correlation].equal?(instance)
235
256
 
236
- checkpoint(pm, correlation, instance, domain, pending: nil)
257
+ checkpoint(process_manager, correlation, instance, domain, pending: nil)
237
258
  end
238
259
  end
239
260
 
240
- def deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
241
- args = dispatch_args(pm, spec, event, instance, correlation)
242
- record = { process_manager: pm.name, instance: correlation, dispatch: spec.command_name }
261
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- the checkpoint/
262
+ # dispatch/compensation ledger protocol reads best as one sequence.
263
+ def deliver_saga_dispatch(process_manager, spec, event, instance, correlation, domain)
264
+ args = dispatch_args(process_manager, spec, event, instance, correlation)
265
+ record = { process_manager: process_manager.name, instance: correlation, dispatch: spec.command_name }
243
266
 
244
267
  # THE RAW INPUTS `args` WAS RESOLVED FROM, captured alongside the
245
268
  # result — never re-derived from history[:saga_instances] later
@@ -251,8 +274,9 @@ module Hecks
251
274
  # no givens/from is skipped by lifecycle_guard_and_given_
252
275
  # violations_are_refused.
253
276
  unless spec.with_spec.to_a.empty?
254
- @registry.saga_dispatch_log << { process_manager: pm.name, instance: correlation, dispatch: spec.command_name,
255
- on: event.name, correlation_head: pm.correlation_head,
277
+ @registry.saga_dispatch_log << { process_manager: process_manager.name, instance: correlation,
278
+ dispatch: spec.command_name,
279
+ on: event.name, correlation_head: process_manager.correlation_head,
256
280
  event_payload: event.payload, memory: Value.materialize(instance[:memory]),
257
281
  with_spec: spec.with_spec, args: args }
258
282
  end
@@ -270,7 +294,7 @@ module Hecks
270
294
  # past `from_state`.
271
295
  @registry.saga_log << record.merge(delivered: false,
272
296
  reason: "reaction depth #{@door.max_reaction_depth} reached")
273
- unwind(pm, event, instance, correlation, domain)
297
+ unwind(process_manager, event, instance, correlation, domain)
274
298
  return
275
299
  end
276
300
 
@@ -292,9 +316,9 @@ module Hecks
292
316
  # one that failed — never left recorded for a refusal that
293
317
  # was never this leg's own to compensate.
294
318
  if spec.compensates && !compensation_recorded
295
- resolved = dispatch_args(pm, spec.compensates, event, instance, correlation)
319
+ resolved = dispatch_args(process_manager, spec.compensates, event, instance, correlation)
296
320
  instance[:completed_compensations] << { command_name: spec.compensates.command_name, args: resolved }
297
- checkpoint(pm, correlation, instance, domain)
321
+ checkpoint(process_manager, correlation, instance, domain)
298
322
  compensation_recorded = true
299
323
  end
300
324
 
@@ -303,23 +327,23 @@ module Hecks
303
327
  verb: qualified(spec.command_name, domain),
304
328
  projected: args,
305
329
  explicit: ReactionInvocation.projection_declared?(spec),
306
- passthrough: [pm.correlation_head],
330
+ passthrough: [process_manager.correlation_head],
307
331
  source_receiver: { aggregate: event.aggregate, identity: event.id }
308
332
  )
309
333
  @door.reenter(qualified(spec.command_name, domain),
310
- saga_correlation: { pm.correlation_head.to_s => correlation }, **invocation)
334
+ saga_correlation: { process_manager.correlation_head.to_s => correlation }, **invocation)
311
335
  @registry.saga_log << record.merge(delivered: true)
312
- rescue *DOMAIN_REFUSALS => error
313
- unrecord_compensation(instance, correlation, domain, pm) if compensation_recorded
336
+ rescue *DOMAIN_REFUSALS => e
337
+ unrecord_compensation(instance, correlation, domain, process_manager) if compensation_recorded
314
338
  # Same rule as the policy interpreter : a refusal by the target is
315
339
  # a recorded outcome, and the leg that raised it UNWINDS — see
316
340
  # `unwind`'s own comment for why the procedure runs its
317
341
  # compensation here rather than leaving the money (or whatever
318
342
  # else a leg moved) sitting out.
319
- @registry.saga_log << record.merge(delivered: false, reason: error.message)
320
- unwind(pm, event, instance, correlation, domain)
321
- rescue StandardError => error
322
- unrecord_compensation(instance, correlation, domain, pm) if compensation_recorded
343
+ @registry.saga_log << record.merge(delivered: false, reason: e.message)
344
+ unwind(process_manager, event, instance, correlation, domain)
345
+ rescue StandardError => e
346
+ unrecord_compensation(instance, correlation, domain, process_manager) if compensation_recorded
323
347
  compensation_recorded = false
324
348
  # A DEFECT, not a refusal — see PolicyInterpreter#deliver's own
325
349
  # comment for the full reasoning: the same DOMAIN_REFUSALS split,
@@ -342,19 +366,20 @@ module Hecks
342
366
  # tag is for.
343
367
  attempt += 1
344
368
  if attempt <= MAX_DEFECT_RETRIES
345
- @registry.saga_log << record.merge(delivered: false, reason: error.message,
346
- defect: true, error_class: error.class.name,
369
+ @registry.saga_log << record.merge(delivered: false, reason: e.message,
370
+ defect: true, error_class: e.class.name,
347
371
  attempt: attempt, retrying: true)
348
372
  retry
349
373
  end
350
374
 
351
- warn "[hecks] defect in saga #{pm.name} — instance #{correlation.inspect} " \
352
- "dispatching #{spec.command_name} after #{attempt} attempts: #{error.class}: #{error.message}"
353
- @registry.saga_log << record.merge(delivered: false, reason: error.message, defect: true,
354
- error_class: error.class.name, defect_compensated: true)
355
- unwind(pm, event, instance, correlation, domain)
375
+ warn "[hecks] defect in saga #{process_manager.name} — instance #{correlation.inspect} " \
376
+ "dispatching #{spec.command_name} after #{attempt} attempts: #{e.class}: #{e.message}"
377
+ @registry.saga_log << record.merge(delivered: false, reason: e.message, defect: true,
378
+ error_class: e.class.name, defect_compensated: true)
379
+ unwind(process_manager, event, instance, correlation, domain)
356
380
  end
357
381
  end
382
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
358
383
 
359
384
  # THE ROLLBACK HALF of `deliver_saga_dispatch`'s own speculative
360
385
  # pre-record (that method's own comment for why it has to be
@@ -367,9 +392,9 @@ module Hecks
367
392
  # push and this leg's own return, and a nested refusal that
368
393
  # consumed it already popped it itself — this rollback only ever
369
394
  # runs for THIS leg's own, still-present entry).
370
- def unrecord_compensation(instance, correlation, domain, pm)
395
+ def unrecord_compensation(instance, correlation, domain, process_manager)
371
396
  instance[:completed_compensations].pop
372
- checkpoint(pm, correlation, instance, domain)
397
+ checkpoint(process_manager, correlation, instance, domain)
373
398
  end
374
399
 
375
400
  # A refused leg UNWINDS — the procedure runs the leg declared `on :refused`,
@@ -388,25 +413,28 @@ module Hecks
388
413
  # flag to stop it: the state moves to the compensating leg's to_state BEFORE
389
414
  # its dispatches run, so a second refusal finds the instance no longer in
390
415
  # from_state and records that instead. The check is the guard.
391
- def unwind(pm, event, instance, correlation, domain)
392
- handler = pm.handler_for(REFUSED)
393
- return unless handler && instance
416
+ def unwind(process_manager, event, instance, correlation, domain)
417
+ return unless instance && process_manager.handles?(REFUSED)
394
418
 
395
- record = { process_manager: pm.name, on: REFUSED, instance: correlation }
419
+ record = { process_manager: process_manager.name, on: REFUSED, instance: correlation }
420
+ handler = nil
396
421
  pre_state = nil
397
422
 
398
423
  # Same non-reentrancy reasoning as `advance_saga`'s own comment —
399
424
  # the mutex covers only the check-and-mutate-and-checkpoint step.
400
425
  advanced = @registry.saga_mutex.synchronize do
401
- unless instance[:state] == handler.from_state
426
+ # The compensating leg is selected by (REFUSED, current state)
427
+ # too — C10.3, one rule for every leg.
428
+ handler = process_manager.handler_for(REFUSED, instance[:state])
429
+ unless handler
402
430
  @registry.saga_log << record.merge(advanced: false,
403
- reason: "in #{instance[:state].inspect}, not #{handler.from_state.inspect}")
431
+ reason: leg_mismatch(process_manager, REFUSED, instance[:state]))
404
432
  next false
405
433
  end
406
434
 
407
435
  pre_state = instance[:state]
408
436
  instance[:state] = handler.to_state
409
- checkpoint(pm, correlation, instance, domain,
437
+ checkpoint(process_manager, correlation, instance, domain,
410
438
  pending: pending_marker(event, handler, pre_state, instance[:state]))
411
439
  true
412
440
  end
@@ -419,7 +447,7 @@ module Hecks
419
447
  # `drain_compensations: true` — only `unwind`'s own call site
420
448
  # fires derived compensation; `advance_saga`'s own call never
421
449
  # does.
422
- settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state,
450
+ settle_transition(process_manager, event, handler, instance, correlation, domain, record, pre_state,
423
451
  drain_compensations: true)
424
452
  end
425
453
 
@@ -434,8 +462,8 @@ module Hecks
434
462
  # distinctly in the log instead of recording it identically to an
435
463
  # ordinary failed delivery, and every OTHER completed compensation
436
464
  # still queued still gets its own attempt.
437
- def deliver_derived_compensation(pm, entry, correlation, domain)
438
- record = { process_manager: pm.name, instance: correlation, dispatch: entry[:command_name] }
465
+ def deliver_derived_compensation(process_manager, entry, correlation, domain)
466
+ record = { process_manager: process_manager.name, instance: correlation, dispatch: entry[:command_name] }
439
467
 
440
468
  attempt = 0
441
469
  begin
@@ -444,36 +472,37 @@ module Hecks
444
472
  verb: qualified(entry[:command_name], domain),
445
473
  projected: entry[:args],
446
474
  explicit: true,
447
- passthrough: [pm.correlation_head],
475
+ passthrough: [process_manager.correlation_head],
448
476
  source_receiver: nil
449
477
  )
450
478
  @door.reenter(qualified(entry[:command_name], domain),
451
- saga_correlation: { pm.correlation_head.to_s => correlation }, **invocation)
479
+ saga_correlation: { process_manager.correlation_head.to_s => correlation }, **invocation)
452
480
  @registry.saga_log << record.merge(delivered: true, compensation: true)
453
- rescue *DOMAIN_REFUSALS => error
454
- @registry.saga_log << record.merge(delivered: false, reason: error.message, compensation: true, compensation_failed: true)
455
- rescue StandardError => error
481
+ rescue *DOMAIN_REFUSALS => e
482
+ @registry.saga_log << record.merge(delivered: false, reason: e.message, compensation: true,
483
+ compensation_failed: true)
484
+ rescue StandardError => e
456
485
  attempt += 1
457
486
  if attempt <= MAX_DEFECT_RETRIES
458
- @registry.saga_log << record.merge(delivered: false, reason: error.message, compensation: true,
459
- defect: true, error_class: error.class.name,
487
+ @registry.saga_log << record.merge(delivered: false, reason: e.message, compensation: true,
488
+ defect: true, error_class: e.class.name,
460
489
  attempt: attempt, retrying: true)
461
490
  retry
462
491
  end
463
492
 
464
- warn "[hecks] defect compensating saga #{pm.name} — instance #{correlation.inspect} " \
465
- "dispatching #{entry[:command_name]} after #{attempt} attempts: #{error.class}: #{error.message}"
466
- @registry.saga_log << record.merge(delivered: false, reason: error.message, compensation: true,
467
- defect: true, error_class: error.class.name, compensation_failed: true)
493
+ warn "[hecks] defect compensating saga #{process_manager.name} — instance #{correlation.inspect} " \
494
+ "dispatching #{entry[:command_name]} after #{attempt} attempts: #{e.class}: #{e.message}"
495
+ @registry.saga_log << record.merge(delivered: false, reason: e.message, compensation: true,
496
+ defect: true, error_class: e.class.name, compensation_failed: true)
468
497
  end
469
498
  end
470
499
 
471
- def dispatch_args(pm, spec, event, instance, correlation)
500
+ def dispatch_args(process_manager, spec, event, instance, correlation)
472
501
  ReactionInvocation.resolve_mapping(
473
502
  with_spec: spec.with_spec,
474
503
  scopes: [["current event payload", event.payload], ["opening event memory", instance[:memory]]],
475
- bindings: { pm.correlation_head => correlation },
476
- label: "#{pm.name}'s dispatch #{spec.command_name}"
504
+ bindings: { process_manager.correlation_head => correlation },
505
+ label: "#{process_manager.name}'s dispatch #{spec.command_name}"
477
506
  )
478
507
  end
479
508
 
@@ -481,21 +510,21 @@ module Hecks
481
510
  command_name.include?("::") ? command_name : "#{domain}::#{command_name}"
482
511
  end
483
512
 
484
- def end_saga(pm, event, domain)
485
- return unless event.name == pm.ends_on
513
+ def end_saga(process_manager, event, domain)
514
+ return unless event.name == process_manager.ends_on
486
515
 
487
- correlation = saga_correlation(pm, event)
516
+ correlation = saga_correlation(process_manager, event)
488
517
  return if correlation.to_s.empty?
489
518
 
490
519
  ended = @registry.saga_mutex.synchronize do
491
- next false unless @registry.saga_instances[pm.name].delete(correlation)
520
+ next false unless @registry.saga_instances[process_manager.name].delete(correlation)
492
521
 
493
- @registry.saga_persistence(domain).delete_saga(process_manager: pm.name, correlation: correlation)
522
+ @registry.saga_persistence(domain).delete_saga(process_manager: process_manager.name, correlation: correlation)
494
523
  true
495
524
  end
496
525
  return unless ended
497
526
 
498
- @registry.saga_log << { process_manager: pm.name, on: event.name,
527
+ @registry.saga_log << { process_manager: process_manager.name, on: event.name,
499
528
  instance: correlation, ended: true }
500
529
  end
501
530
  end
@@ -37,9 +37,12 @@ module Hecks
37
37
  # worse defect than the stall it would replace. So this is real,
38
38
  # durable, crash-surviving VISIBILITY into exactly what a stalled
39
39
  # saga was doing when the process died — the missing half of "no
40
- # reconciliation pass exists" not the full pending → claimed →
41
- # delivered outbox `future-features.md` still lists as unbuilt, and
42
- # not a substitute for it.
40
+ # reconciliation pass exists". The full pending → claimed →
41
+ # delivered outbox now exists too (`Runtime::Outbox`, ADR 0053) and
42
+ # is COMPLEMENTARY, not a replacement: the outbox row names the
43
+ # EVENT owed to this saga (and redrives it if the saga never got to
44
+ # claim it); this marker names the saga's own LEG mid-flight after
45
+ # the event was delivered. A crash can leave either standing.
43
46
  SAGA_PENDING_DISPATCH_KEY = :__hecks_saga_pending_dispatch__
44
47
  end
45
48
  end
@@ -42,6 +42,13 @@ module Hecks
42
42
  Scoped.new(declared, QuerySpecification::Common::WhereClause.new(field: tenant, op: "eq", value: tenant))
43
43
  end
44
44
 
45
+ # A SimpleDelegator wrapping one declared query/read-model spec
46
+ # with its tenant `eq` where-clause appended to #wheres — the
47
+ # object every query engine actually reads, so nothing beyond
48
+ # TenantScope.apply itself has to know the boundary exists. Never
49
+ # handed back to a caller that might call an IR-level method whose
50
+ # own internal `.wheres` read would bypass this override (see this
51
+ # module's own header for why).
45
52
  class Scoped < SimpleDelegator
46
53
  def initialize(declared, clause)
47
54
  super(declared)