hecks 0.3.0 → 1.0.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -0,0 +1,45 @@
1
+ module Hecks
2
+ module Runtime
3
+ # A PROCESS-WIDE, STRIPED MUTEX REGISTRY — the concurrency-control
4
+ # mechanism for every adapter that does NOT declare
5
+ # `:optimistic_concurrency` (Heki, Memory today; see
6
+ # `CommandInterpreter#call`/`EntityInterpreter#call`, which choose
7
+ # between this and Postgres's CAS+retry purely off
8
+ # `repository.capabilities`).
9
+ #
10
+ # WHY A LOCK SUFFICES HERE AND CAS IS NOT NEEDED: both adapters hold
11
+ # process-local data. `Adapters::Memory.tenant_capable?`'s own comment
12
+ # states the confirmed fact this relies on — two `Runtime.boot` calls
13
+ # get two entirely separate adapter instances; there is never a SECOND
14
+ # PROCESS writing the same Heki file or the same Memory Hash, only
15
+ # possibly other THREADS within this one process. A `Mutex` held for
16
+ # the full hydrate-through-save critical section closes the identical
17
+ # lost-update gap CAS closes for Postgres, with no schema, no version
18
+ # column, and no retry loop — the second thread simply doesn't start
19
+ # its own hydrate until the first thread's save has landed.
20
+ #
21
+ # STRIPED, NOT ONE GLOBAL LOCK: keyed by `[domain, aggregate.hecks_name,
22
+ # id]`, so two dispatches against two DIFFERENT records never block
23
+ # each other. The registry Hash itself is guarded by its own top-level
24
+ # Mutex only for the moment a new per-key Mutex is created — two
25
+ # threads locking DIFFERENT keys for the first time never wait on one
26
+ # another beyond that brief creation window.
27
+ module AggregateLock
28
+ @registry_lock = Mutex.new
29
+ @locks = {}
30
+
31
+ class << self
32
+ # `AggregateLock.for(domain, aggregate, id).synchronize { ... }`
33
+ # `id: nil` — identity could not be resolved yet (see
34
+ # `Identity.best_effort`) — locks by aggregate TYPE alone, coarser
35
+ # (every record of this aggregate serializes against every other)
36
+ # but still correct: it can only ever make dispatch MORE
37
+ # conservative than a resolved id would.
38
+ def for(domain, aggregate, id = nil)
39
+ key = id.nil? ? [domain.to_s, aggregate.hecks_name] : [domain.to_s, aggregate.hecks_name, id.to_s]
40
+ @registry_lock.synchronize { @locks[key] ||= Mutex.new }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ module Hecks
2
+ module Runtime
3
+ # A boot's own small set of phase-tagged, conditionally-present gates —
4
+ # ADR 0031. Registration is instance-scoped, ONE PER `Loader.boot`
5
+ # CALL, never a module-level singleton: a process that boots more than
6
+ # one registry in its lifetime (every spec suite does) must never let
7
+ # one boot's capability profile leak into the next boot's gate list.
8
+ #
9
+ # A gate is anything `.call(registry, directory)`-able — an existing
10
+ # module method handed over as a `Method` object (`EraCheck.method
11
+ # (:check_lineage!)`) needs no wrapper; a bare block does. Phases run
12
+ # in the order `run!` is called, gates within a phase in registration
13
+ # order — today exactly one gate per phase, so ordering among
14
+ # same-phase gates has never been exercised.
15
+ #
16
+ # This is deliberately NOT the same registry `Hecks::Projector` uses
17
+ # (ADR 0027) — that one is a process-wide, static IR-in/artifact-out
18
+ # registry with no bindings and no live-state mutation; this one is
19
+ # per-boot and gates real I/O (a Postgres mint, a saga-store read).
20
+ # Sharing a primitive between them is deferred until a third consumer
21
+ # actually wants it (0031's own Rejected Alternatives).
22
+ class BootGates
23
+ def initialize
24
+ @gates = Hash.new { |h, k| h[k] = [] }
25
+ end
26
+
27
+ def register(name, gate, phase:)
28
+ @gates[phase] << [name, gate]
29
+ self
30
+ end
31
+
32
+ def registered?(name)
33
+ @gates.values.flatten(1).any? { |registered_name, _gate| registered_name == name }
34
+ end
35
+
36
+ def run!(phase, registry, directory)
37
+ @gates[phase].each { |pair| pair.last.call(registry, directory) }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -19,15 +19,31 @@ module Hecks
19
19
  # `CommandRules::Authorization` check a real Governance
20
20
  # `RoleAssignment` instead, once the command's domain has Governance
21
21
  # attached — see that module's own header for the full split.
22
- Current = Struct.new(:role, :actor_id, keyword_init: true)
22
+ #
23
+ # `as_of` and `scope` are BOTH optional too, and both self-asserted
24
+ # by the caller rather than derived from the command — deliberately:
25
+ # `as_of` is filled at the door from `Ports::Clock.now`, never
26
+ # inside the interpreter (see `Ports::Clock`'s own header), so
27
+ # there is no other place to source it from. `scope` stays here
28
+ # rather than becoming a command-level DSL construct on purpose —
29
+ # a scope check that lived in the bluebook would put an
30
+ # authorization concern inside the domain declaration itself; this
31
+ # keeps it an application-boundary fact instead, the same shape
32
+ # `role`/`actor_id` already are. The tradeoff: a caller states what
33
+ # scope it is acting in, and `holds_role?` verifies a live grant
34
+ # exists for THAT scope — it does not independently confirm the
35
+ # scope matches whatever the command's own target data belongs to.
36
+ Current = Struct.new(:role, :actor_id, :as_of, :scope, keyword_init: true)
23
37
 
24
38
  module_function
25
39
 
26
40
  def current = Thread.current[:hecks_caller]
27
41
 
28
- def as(role:, actor_id: nil)
42
+ def as(role:, actor_id: nil, as_of: nil, scope: nil)
29
43
  previous = Thread.current[:hecks_caller]
30
- Thread.current[:hecks_caller] = Current.new(role: role.to_s, actor_id: actor_id&.to_s)
44
+ Thread.current[:hecks_caller] = Current.new(
45
+ role: role.to_s, actor_id: actor_id&.to_s, as_of: as_of, scope: scope&.to_s
46
+ )
31
47
  yield
32
48
  ensure
33
49
  Thread.current[:hecks_caller] = previous
@@ -19,8 +19,19 @@ module Hecks
19
19
  # the aggregate rather than describe it : `id`, whatever the aggregate is
20
20
  # identified by, and the reference key of the root a command reaches
21
21
  # through. Refusing those would refuse every dispatch there is.
22
- def refuse_unknown_arguments(domain, aggregate, command, args)
23
- addressing = [:id, *aggregate.identity_heads, reference_key(command)] + correlation_keys(domain)
22
+ #
23
+ # `extra_identity_heads:` EntityInterpreter's own callers only. An
24
+ # entity dispatch addresses not just the root aggregate but every
25
+ # entity ALONG THE CHAIN it walks to reach the piece the command
26
+ # actually belongs to (`Handler.Dispatch.Bind` is two hops), and each
27
+ # hop's own `identity_heads` is addressing the same way the root's
28
+ # are — `element_of` (entity_element.rb) reads them straight out of
29
+ # `args` to find the element, never as a fact the command itself
30
+ # declares. `[]` for a plain aggregate/port-operation dispatch, which
31
+ # has no chain to add.
32
+ def refuse_unknown_arguments(domain, aggregate, command, args, extra_identity_heads: [])
33
+ addressing = [:id, *aggregate.identity_heads, *extra_identity_heads, reference_key(command)] +
34
+ correlation_keys(domain)
24
35
  known = (command.attributes.map(&:name) + addressing).compact.map(&:to_sym)
25
36
  # SORTED. Payload order is whatever the caller happened to write, and
26
37
  # refusal wording is contract — pinned byte-for-byte by the corpus, so
@@ -71,6 +71,18 @@ module Hecks
71
71
  # never through this method.
72
72
  when :delegate
73
73
  nil
74
+ # `corrects` — CommandBuilder#corrects_impl's own comment gives
75
+ # the full reasoning for storing it as a mutation at all. A REAL
76
+ # no-op here too: it targets no field on THIS instance either —
77
+ # its own event name, and whether THIS record has actually
78
+ # emitted it, is checked once, up front, by
79
+ # CommandRules::Admissibility#enforce_correction_target, not
80
+ # here. Whatever field this correction actually changes is an
81
+ # ORDINARY declared (or, for `reverses: true`, derived — see
82
+ # AggregateBuilder#seal_correction_targets) mutation of its own,
83
+ # applied by one of the branches above like any other.
84
+ when :corrects
85
+ nil
74
86
  else
75
87
  # Every declared op has a `when` above — this is not a real
76
88
  # runtime path today, only a backstop against the day one
@@ -9,6 +9,7 @@ require_relative "../ports/persistence/execution"
9
9
  require_relative "instance"
10
10
  require_relative "refusal_wording"
11
11
  require_relative "entity_element"
12
+ require_relative "rebuild_sweep"
12
13
 
13
14
  module Hecks
14
15
  module Runtime
@@ -31,6 +32,16 @@ module Hecks
31
32
  # Runtime::RefusalWording's own doc comment gives the same reason.
32
33
  DISPATCH_ORDER = Hecks::Vocabulary.symbols("AggregateDispatchOrder")
33
34
 
35
+ # A LAST-RESORT SAFETY VALVE, NOT THE NORMAL OUTCOME PATH — see
36
+ # `Runtime::StaleWrite`'s own comment. Two concurrent writers
37
+ # against one aggregate resolve through exactly one retry in the
38
+ # ordinary case (the loser's retried hydrate reads the winner's now-
39
+ # committed state and its own `given` refuses for real, raising
40
+ # `GivenNotMet`, not `StaleWrite`) — this cap exists for pathological
41
+ # contention (many concurrent writers on one hot aggregate), not the
42
+ # two-writer case.
43
+ MAX_STALE_WRITE_RETRIES = 5
44
+
34
45
  # EVERY CROSS-STEP LOCAL `call` used to thread through its own literal
35
46
  # sequence, held in one place now that the sequence is data-driven —
36
47
  # `result` and `transition`/`old_state` default to nil until the step
@@ -49,14 +60,34 @@ module Hecks
49
60
  # dispatch would (givens checked, mutations applied to `ctx.instance`
50
61
  # in memory); `step_save`/`step_emit` are the only two that read this
51
62
  # flag, each skipping its own real work — see their own comments.
63
+ # RETRIES THE WHOLE METHOD BODY on `StaleWrite` — a fresh `ctx`, a
64
+ # fresh `step_hydrate` re-reading current state, so `enforce_givens`
65
+ # re-evaluates against reality rather than the snapshot that just
66
+ # went stale. See `MAX_STALE_WRITE_RETRIES`/`Runtime::StaleWrite`
67
+ # for why exhaustion is a pathological-contention signal, not the
68
+ # expected shape of a two-writer race.
52
69
  def call(domain, aggregate, command, args, correlation = nil, route: nil, dry_run: false)
53
- ctx = Context.new(domain, aggregate, command, args)
54
- ctx.correlation = correlation
55
- ctx.route = route
56
- ctx.dry_run = dry_run
57
- ctx.plan = DependencyPlanning::Analyzer.call(aggregate: aggregate, command: command)
58
- run_dispatch_order(DISPATCH_ORDER, ctx)
59
- [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
70
+ attempt = 0
71
+ begin
72
+ ctx = Context.new(domain, aggregate, command, args)
73
+ ctx.correlation = correlation
74
+ ctx.route = route
75
+ ctx.dry_run = dry_run
76
+ ctx.plan = DependencyPlanning::Analyzer.call(aggregate: aggregate, command: command)
77
+ # RESOLVED HERE, ONCE, BEFORE HYDRATION — `Registry#repository`
78
+ # memoizes, so this and `step_hydrate`'s own read of `ctx.repository`
79
+ # (no second fetch there any more) always name the same instance;
80
+ # the isolation decision below (lock vs. CAS+retry) needs the
81
+ # repository's capabilities before a single step runs.
82
+ ctx.repository = @registry.repository(domain, aggregate)
83
+ lock_id = Identity.best_effort(aggregate, args, route, reference_key: reference_key(command))
84
+ run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
85
+ [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
86
+ rescue StaleWrite
87
+ attempt += 1
88
+ retry if attempt < MAX_STALE_WRITE_RETRIES
89
+ raise
90
+ end
60
91
  end
61
92
 
62
93
  private
@@ -82,7 +113,8 @@ module Hecks
82
113
  end
83
114
 
84
115
  def step_hydrate(ctx)
85
- ctx.repository = @registry.repository(ctx.domain, ctx.aggregate)
116
+ # `ctx.repository` is resolved once, in `#call`, before the
117
+ # isolation decision (lock vs. CAS+retry) — not here any more.
86
118
  ctx.strategy = ctx.plan.strategy_for(capabilities: ctx.repository.capabilities)
87
119
  ctx.instance = step(:hydrate) {
88
120
  if ctx.plan.complete_state? && ctx.plan.state_independent?
@@ -99,6 +131,12 @@ module Hecks
99
131
 
100
132
  def step_enforce_givens(ctx)
101
133
  step(:enforce_givens) {
134
+ # STRUCTURAL, before the declared givens — the same ordering
135
+ # NotFound/AlreadyExists already get at hydration: "does the
136
+ # fact this command's corrects names even exist" is not a
137
+ # domain rule an author wrote, it is a precondition for the
138
+ # domain rules to mean anything at all.
139
+ @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
102
140
  @rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
103
141
  declaring: ctx.aggregate, parent: ctx.instance)
104
142
  }
@@ -219,6 +257,7 @@ module Hecks
219
257
 
220
258
  step(:save) do
221
259
  @rules.resolve_state_references(ctx.domain, ctx.aggregate, ctx.instance.state)
260
+ seed_projected_fields(ctx)
222
261
  ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
223
262
  # A SECOND CREATION IS NOT A FRESH ONE — see
224
263
  # hydrate_legacy_creation's own comment; the
@@ -233,18 +272,63 @@ module Hecks
233
272
  # this strategy exists to skip.
234
273
  ctx.repository.atomic_put(ctx.instance, insert_only: ctx.command.creates?)
235
274
  else
236
- ctx.repository.save(ctx.instance)
237
- Ports::Persistence::Outcome.new(status: :saved, instance: ctx.instance)
275
+ # `expected_version:` is `ctx.instance.version` — nil for a
276
+ # brand-new record (never read from storage) or when the
277
+ # repository isn't CAS-capable, either of which falls straight
278
+ # through to a plain, unconditional save inside `AppendOnly#save`.
279
+ ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
238
280
  end
239
281
  if ctx.persistence_outcome.status == :conflicted
240
282
  raise(AlreadyExists, RefusalWording.render("AlreadyExists", "creating_duplicate",
241
283
  command: ctx.command.hecks_name, aggregate: ctx.aggregate.hecks_name,
242
284
  identity: identity_reading(ctx.aggregate),
243
285
  offered: Rendering.describe(ctx.instance.id)))
286
+ elsif ctx.persistence_outcome.status == :stale
287
+ # NOT a `RefusalWording.render` call — this is not a declared
288
+ # vocabulary refusal, just a plain, informative message. See
289
+ # `Runtime::StaleWrite`'s own comment: caught by `#call`'s
290
+ # retry loop, re-raised only once retries are exhausted.
291
+ raise(StaleWrite,
292
+ "#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
293
+ "(#{identity_reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
294
+ "another write committed against this record after it was read")
244
295
  end
245
296
  end
246
297
  end
247
298
 
299
+ # THE ONE-TIME, SYNCHRONOUS HALF OF `projects` (S12, ADR 0025) —
300
+ # `RebuildSweep` (`runtime/rebuild_sweep.rb`) is deliberately the
301
+ # ONLY thing that keeps a projected field current against a
302
+ # target that changes AFTER this record was written — no reactive
303
+ # `Policy#for_each` keeping it live in real time, that stays
304
+ # deferred, same as that file's own header explains. But without
305
+ # SOME synchronous population, a projected field never gets an
306
+ # INITIAL value at all until an operator remembers to run a
307
+ # sweep by hand — every acting command reading it (`given
308
+ # ("customer is active") { customer_status == "active" }`, say)
309
+ # would refuse a freshly created, genuinely active record for no
310
+ # real reason, which is not the eventual-consistency tradeoff the
311
+ # ADR accepts, just a bug. So: every time a record with `projects`
312
+ # fields is about to save — creating or acting, either can be the
313
+ # first time a referenced record resolves — read each one ONCE,
314
+ # here, using the exact same `RebuildSweep.remote_value` a sweep
315
+ # itself would compute. This is still eventually consistent in
316
+ # the sense the ADR means: a change on the TARGET side after this
317
+ # save still needs a sweep to reach here. It is only ever
318
+ # SYNCHRONOUS with THIS record's own write, never a live read
319
+ # triggered by a `given`/`ensures`/`invariant` mid-dispatch — the
320
+ # boundary rule those enforce holds exactly as before.
321
+ def seed_projected_fields(ctx)
322
+ return if ctx.aggregate.projected_fields.empty?
323
+
324
+ ctx.aggregate.projected_fields.each do |field|
325
+ value = RebuildSweep.remote_value(@registry, ctx.domain, ctx.aggregate, ctx.instance.state, field)
326
+ next if value.nil?
327
+
328
+ ctx.instance.state[field.name] = value
329
+ end
330
+ end
331
+
248
332
  # A DELEGATING COMMAND EMITS NOTHING OF ITS OWN (`CommandBuilder#build`'s
249
333
  # own guard refuses declaring `emits` alongside `delegates_to`) — its
250
334
  # result IS whatever `step_delegate_to_entity` already collected from
@@ -321,7 +405,7 @@ module Hecks
321
405
  offered: Rendering.describe(id)))
322
406
  end
323
407
 
324
- Instance.new(aggregate: aggregate, id: id)
408
+ Instance.new(aggregate: aggregate, id: id, args: args)
325
409
  end
326
410
 
327
411
  def hydrate_complete_state(repository, aggregate, command, args, route, strategy)
@@ -355,7 +439,7 @@ module Hecks
355
439
  offered: Rendering.describe(id)))
356
440
  end
357
441
 
358
- Instance.new(aggregate: aggregate, id: id)
442
+ Instance.new(aggregate: aggregate, id: id, args: args)
359
443
  end
360
444
 
361
445
  # A complete command may still depend on prior state: lifecycle guards
@@ -398,7 +482,7 @@ module Hecks
398
482
  offered: Rendering.describe(id)))
399
483
  end
400
484
 
401
- found ? found.dup : Instance.new(aggregate: aggregate, id: id)
485
+ found ? found.dup : Instance.new(aggregate: aggregate, id: id, args: args)
402
486
  end
403
487
 
404
488
  # THE JOIN, THE DIG, AND THE READING — all shared with `EntityInterpreter`
@@ -118,9 +118,23 @@ module Hecks
118
118
  # nil for an aggregate command — CommandInterpreter never passes it.
119
119
  def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil)
120
120
  state = GuardState.new(subject)
121
- owner = subject.aggregate if subject.respond_to?(:aggregate)
122
- attrs = dereference(domain, owner, subject).merge(args).merge(dereference(domain, command, args))
123
- attrs = attrs.merge(parent: parent.state.merge(dereference(domain, parent.aggregate, parent.state))) if parent
121
+ # A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
122
+ # ADR 0025) `subject`'s own STORED references are no longer
123
+ # dereferenced here at all. What used to be a live query against
124
+ # another aggregate's own repository is now just `subject`'s own
125
+ # state: a `projects :customer_status, from: :"customer.status"`
126
+ # field is a REGULAR stored attribute, already present in
127
+ # `subject`/`state` with no hydration step needed. `dereference`
128
+ # is still called on `command`/`args`, below — that is a
129
+ # DIFFERENT case the ADR explicitly keeps in bounds ("its command
130
+ # arguments"): a reference-typed ARGUMENT this dispatch was just
131
+ # handed (`Dispute`'s own `disputed_by`, say) has nothing stored
132
+ # to project yet, so resolving it here, once, synchronously with
133
+ # THIS command's own admission, is not the live-query-against-
134
+ # another-aggregate's-stored-state pattern the boundary rule
135
+ # forbids.
136
+ attrs = args.merge(dereference(domain, command, args))
137
+ attrs = attrs.merge(parent: parent.state) if parent
124
138
  command.givens.each do |given|
125
139
  next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
126
140
 
@@ -130,6 +144,32 @@ module Hecks
130
144
  enforce_lifecycle_guard(declaring, command, subject) if declaring
131
145
  end
132
146
 
147
+ # `corrects` — CommandBuilder#corrects_impl's own comment. NOT
148
+ # expressible as an ordinary `given`: "has this exact record
149
+ # already emitted this exact event" is not a predicate over the
150
+ # record's OWN fields, it is a fact about the event log, so it is
151
+ # raised structurally here, the same way NotFound/AlreadyExists
152
+ # are, rather than through the expression evaluator. The build-
153
+ # time half — does ANYTHING in this aggregate ever emit the named
154
+ # event at all — is `AggregateBuilder#seal_correction_targets`;
155
+ # this is the dispatch-time half — has THIS record actually done
156
+ # so yet.
157
+ def enforce_correction_target(instance, aggregate, command, domain:)
158
+ command.mutations.each do |mutation|
159
+ next unless mutation.op == :corrects
160
+
161
+ event_key = "#{domain}::#{aggregate.hecks_name}"
162
+ event_name = mutation.target.to_s
163
+ next if @registry.event_log.any? do |event|
164
+ event.name == event_name && event.aggregate == event_key && event.id == instance.id
165
+ end
166
+
167
+ raise NothingToCorrect,
168
+ "#{command.hecks_name} refused — corrects #{event_name}, but " \
169
+ "#{event_key} ##{instance.id} has never emitted it"
170
+ end
171
+ end
172
+
133
173
  # LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — `command
134
174
  # "Debit", from: "open"` checked here, folded into the SAME
135
175
  # dispatch step `given` already runs at (both are preconditions,
@@ -177,13 +217,14 @@ module Hecks
177
217
  # field the command just took in to mutate it.
178
218
  def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
179
219
  state = GuardState.new(subject)
180
- owner = subject.aggregate if subject.respond_to?(:aggregate)
181
- # Same merge-order reasoning as enforce_givens above: an
182
- # aliased command-level reference must override its own raw
183
- # id argument, not the other way round. `old` still wins over
184
- # everything, unchanged.
185
- attrs = dereference(domain, owner, subject).merge(args).merge(dereference(domain, command, args))
186
- attrs = attrs.merge(parent: parent.state.merge(dereference(domain, parent.aggregate, parent.state))) if parent
220
+ # S12, ADR 0025 — same boundary reasoning as enforce_givens
221
+ # above: `subject`'s own stored references are no longer
222
+ # dereferenced here; a `projects`-maintained field is already
223
+ # part of `state`. `command`/`args` still dereferences — a
224
+ # fresh reference-typed ARGUMENT stays in bounds.
225
+ # `old` still wins over everything, unchanged.
226
+ attrs = args.merge(dereference(domain, command, args))
227
+ attrs = attrs.merge(parent: parent.state) if parent
187
228
  attrs = attrs.merge(old: old)
188
229
  command.ensures.each do |rule|
189
230
  next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
@@ -197,16 +238,23 @@ module Hecks
197
238
  # ensures` already checks at, and for the same reason: an
198
239
  # invariant is a claim about the SETTLED record, not the
199
240
  # command that produced it, so it reads no `args`/`old` at all,
200
- # only the record's own (dereferenced) state. `subject` here is
241
+ # only the record's own state. `subject` here is
201
242
  # always the AGGREGATE's own instance — `CommandInterpreter`
202
243
  # passes its own `ctx.instance`, and `EntityInterpreter` passes
203
244
  # the PARENT record (`ctx.instance`, not the element), since an
204
245
  # entity mutation changes data inside the SAME aggregate
205
246
  # boundary the invariant guards; there is no separate "entity
206
247
  # invariant" to check the piece's own view against.
248
+ #
249
+ # NO `dereference` (S12, ADR 0025) — an invariant may only read
250
+ # `subject`'s own boundary, same rule `enforce_givens`/
251
+ # `enforce_ensures` now hold to. No invariant in the corpus has
252
+ # ever read across a `reference_to` (verified before this
253
+ # change), so this is not a migration, just closing the same
254
+ # capability off here that was already unused.
207
255
  def enforce_invariants(subject, aggregate, domain:)
208
256
  state = GuardState.new(subject)
209
- attrs = dereference(domain, aggregate, subject)
257
+ attrs = {}
210
258
  aggregate.invariants.each do |invariant|
211
259
  next if Bluebook::Expression::Evaluator.call(invariant.canonical, state, attrs)
212
260
 
@@ -247,8 +295,10 @@ module Hecks
247
295
  Array(owner_instance[list_attr.name]).each do |element|
248
296
  wrapped = Instance.new(aggregate: entity, id: nil, state: element)
249
297
  element_state = GuardState.new(wrapped)
250
- attrs = dereference(domain, entity, wrapped)
251
- .merge(parent: owner_instance.state.merge(dereference(domain, owner_construct, owner_instance)))
298
+ # NO `dereference` (S12, ADR 0025) — same boundary rule as
299
+ # enforce_invariants above; `parent` (the owner's own
300
+ # state, projected fields included) stays readable.
301
+ attrs = { parent: owner_instance.state }
252
302
 
253
303
  entity.invariants.each do |invariant|
254
304
  next if Bluebook::Expression::Evaluator.call(invariant.canonical, element_state, attrs)
@@ -45,7 +45,13 @@ module Hecks
45
45
  # what Vocabulary::MutationOp declares — MutationApplier's own
46
46
  # `when :delegate` branch (mutation_applier.rb) never calls
47
47
  # #sign_of either, same as `remove`'s own note above.
48
- MutationOp.new(name: "delegate", sign: nil)
48
+ MutationOp.new(name: "delegate", sign: nil),
49
+ # CommandBuilder#corrects_impl's own comment gives the full
50
+ # reasoning — a command amending a past event rather than
51
+ # acting fresh. Carries no sign, like delegate: it does no
52
+ # arithmetic of its own; the record's actual change, if any,
53
+ # is an ordinary `sets` declared alongside it.
54
+ MutationOp.new(name: "corrects", sign: nil)
49
55
  ].freeze
50
56
 
51
57
  # A mutation's source is either the NAME OF AN ARGUMENT or a LITERAL, and
@@ -37,7 +37,8 @@ module Hecks
37
37
 
38
38
  authorized =
39
39
  if caller.actor_id && governance_attached?(domain)
40
- Ports::Authorization.holds_role?(registry, actor_id: caller.actor_id, role: command.role)
40
+ Ports::Authorization.holds_role?(registry, actor_id: caller.actor_id, role: command.role,
41
+ as_of: caller.as_of, scope: caller.scope)
41
42
  else
42
43
  caller.role == command.role
43
44
  end
@@ -128,31 +128,39 @@ module Hecks
128
128
  unwrapped.to_s
129
129
  end
130
130
 
131
- # A related record's OWN fields, reachable by name from `given`/
132
- # `ensures` — `customer.status`, `account.customer.status` — without
131
+ # A COMMAND ARGUMENT's own related record, reachable by name from
132
+ # `given`/`ensures` — `disputed_by.status`, say, `CardPayment
133
+ # .Dispute`'s own fresh `Reference<Customer>` argument — without
133
134
  # teaching the pure expression evaluator anything about
134
135
  # repositories. The lookup happens HERE, once, before evaluation;
135
136
  # `Resolver#lookup` just digs into a plain Hash exactly as it
136
137
  # always has.
137
138
  #
138
- # `owner` is either the declaring aggregate/entity (its OWN
139
- # `reference_to`, read off `source` the record's stored state)
140
- # or the command itself (a reference-typed ARGUMENT, read off
141
- # `source` the dispatch payload). Same shape either way: every
142
- # reference-typed attribute `owner` declares becomes a key in the
143
- # result, named by stripping the attribute's own `_id` suffix
144
- # (`customer_id` `customer`) a no-op for an aliased reference
145
- # that already carries no suffix (`reference_to Account, as:
146
- # :source` `source`), which is why this needs no separate case
147
- # for `as:`.
139
+ # `owner` NARROWED TO `command` ONLY (S12, ADR 0025 — "rules
140
+ # confined to their own aggregate boundary"): dereferencing the
141
+ # DECLARING aggregate/entity's own STORED `reference_to` used to
142
+ # be the other half of this method's job — a live query against
143
+ # another aggregate's own repository, every time a `given`/
144
+ # `ensures`/`invariant` ran. That half is gone; a cross-aggregate
145
+ # fact a rule needs now has to be a `projects`-maintained LOCAL
146
+ # field (`AggregateBuilder#projects_impl`'s own comment), already
147
+ # present in `subject`'s own state, no hydration needed. A
148
+ # reference-typed COMMAND ARGUMENT stays in bounds, though — the
149
+ # ADR's own boundary list names "its command arguments" as
150
+ # readable, and nothing is stored yet for a fresh argument to
151
+ # project from; resolving it once here, synchronous with THIS
152
+ # command's own admission, is a different shape from a live query
153
+ # against an ALREADY-PERSISTED reference. `enforce_givens`/
154
+ # `enforce_ensures` are this method's only two remaining callers,
155
+ # both passing `command`/`args`, never a `subject`'s own
156
+ # aggregate — verified before this comment was written, not
157
+ # assumed.
148
158
  #
149
- # RECURSES into what it finds, so a chain like
150
- # `account.customer.status` resolves in one pass: hydrating
151
- # `account` also hydrates ITS OWN `customer_id` into a nested
152
- # `customer` key. Depth-bounded rather than cycle-detected — nothing
153
- # in this corpus dots more than two hops, and a bound is simpler
154
- # than tracking visited (type, id) pairs for a cycle nothing here
155
- # declares.
159
+ # RECURSES into what it finds, so a chain deeper than one hop
160
+ # still resolves in one pass. Depth-bounded rather than cycle-
161
+ # detected nothing in this corpus dots more than two hops on a
162
+ # fresh argument, and a bound is simpler than tracking visited
163
+ # (type, id) pairs for a cycle nothing here declares.
156
164
  DEREFERENCE_DEPTH = 4
157
165
  private_constant :DEREFERENCE_DEPTH
158
166
 
@@ -69,6 +69,20 @@ module Hecks
69
69
  @command = command
70
70
  @owner_fields = aggregate.attributes.to_set(&:name)
71
71
  @owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
72
+ # `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
73
+ # `given`/`ensures` reading one (e.g. `customer_status ==
74
+ # "active"`) is reading this record's own stored field, same
75
+ # as any attribute, even though nothing here WRITES it via a
76
+ # declared mutation (`CommandInterpreter#seed_projected_fields`
77
+ # populates it outside this analysis entirely). Left out of
78
+ # `known_writes` deliberately: `add_preservation_reads` then
79
+ # correctly treats it as a prior-state read that must survive
80
+ # a partial mutation, which is exactly right — a projected
81
+ # field's freshness comes from the interpreter reseeding it on
82
+ # save, not from anything a caller-supplied write set carries.
83
+ if aggregate.respond_to?(:projected_fields)
84
+ aggregate.projected_fields.each { |field| @owner_fields << field.name }
85
+ end
72
86
  @payload_fields = command.attributes.to_set(&:name)
73
87
  @state_reads = Set.new
74
88
  @payload_reads = Set.new
@@ -256,15 +256,30 @@ module Hecks
256
256
  # is cleared for the reaction's own dispatch, so a triggering
257
257
  # caller's role can neither satisfy nor block a reaction command it
258
258
  # has nothing to do with (Runtime::Caller.without).
259
+ #
260
+ # `Thread.current[:hecks_reaction_depth]`, not a plain ivar — this
261
+ # `Dispatcher` instance is a single object shared by every thread
262
+ # dispatching through it (a Puma worker pool, say), so a plain ivar
263
+ # here is exactly the known Puma-concurrency bug class: two
264
+ # concurrent top-level dispatches on different threads would
265
+ # increment/decrement the SAME counter, letting one thread's nested
266
+ # reaction depth leak into another thread's unrelated dispatch. A
267
+ # `Mutex` is not the answer either — a reaction cascade re-enters
268
+ # `reenter` on the SAME thread (see `SagaInterpreter#advance_saga`'s
269
+ # own comment on why a non-reentrant `Mutex` can't guard this).
270
+ # `Thread.current`-backed, saved/restored around the call with a
271
+ # plain local + `ensure`, is the same idiom `Runtime::Caller`
272
+ # (`caller.rb`) already established for exactly this shape of
273
+ # per-thread ambient state.
259
274
  def reenter(verb, saga_correlation: nil, **args)
260
- depth = @reaction_depth.to_i
261
- @reaction_depth = depth + 1
275
+ depth = Thread.current[:hecks_reaction_depth].to_i
276
+ Thread.current[:hecks_reaction_depth] = depth + 1
262
277
  Caller.without { dispatch(verb, saga_correlation: saga_correlation, **args) }
263
278
  ensure
264
- @reaction_depth = depth
279
+ Thread.current[:hecks_reaction_depth] = depth
265
280
  end
266
281
 
267
- def reaction_depth_reached? = @reaction_depth.to_i >= MAX_REACTION_DEPTH
282
+ def reaction_depth_reached? = Thread.current[:hecks_reaction_depth].to_i >= MAX_REACTION_DEPTH
268
283
  def max_reaction_depth = MAX_REACTION_DEPTH
269
284
 
270
285
  private