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
@@ -1,3 +1,5 @@
1
+ require_relative "../saga_pending_dispatch"
2
+
1
3
  module Hecks
2
4
  module Runtime
3
5
  class Registry
@@ -20,8 +22,27 @@ module Hecks
20
22
  # shares one adapter), and is the honest, documented fallback
21
23
  # for the rarer domain genuinely split across more than one
22
24
  # local adapter with no default declared.
25
+ #
26
+ # GENUINELY LAZY, GENUINELY POST-BOOT — unlike `rehydrate_sagas!`
27
+ # below (boot-only), this is called from live dispatch
28
+ # (`SagaInterpreter#checkpoint`/`#end_saga`, on every saga
29
+ # transition), so the FIRST call for a given domain can come from
30
+ # any dispatching thread, not just the boot thread. `@saga_persistence`
31
+ # itself is a plain Hash stood up once in `Registry#initialize` (no
32
+ # race on the container), but `resolve_saga_persistence` is real work
33
+ # (a `BindingPolicy.resolve` plus a lazy `repository` build) whose
34
+ # RESULT — the actual adapter instance a domain's sagas persist
35
+ # through — must be the SAME object for every caller: two threads
36
+ # racing the first lookup and each building their own adapter would
37
+ # silently split one domain's saga writes across two adapter
38
+ # instances (worse than `Dispatcher#reaction_depth`'s M20 — that bug
39
+ # corrupted a counter; this one can corrupt WHICH STORE a saga's
40
+ # state lands in). Double-checked locking against a DEDICATED mutex
41
+ # — never `@saga_mutex` — see `Registry#initialize`'s own comment for
42
+ # why reusing that one would deadlock.
23
43
  def saga_persistence(domain)
24
- (@saga_persistence ||= {})[domain.to_s] ||= resolve_saga_persistence(domain.to_s)
44
+ key = domain.to_s
45
+ @saga_persistence[key] || @saga_persistence_mutex.synchronize { @saga_persistence[key] ||= resolve_saga_persistence(key) }
25
46
  end
26
47
 
27
48
  # WALKS EVERY LOADED DOMAIN, repopulating `saga_instances` from
@@ -34,10 +55,23 @@ module Hecks
34
55
  # boot (`Loader.boot`, between `verify!` and dispatcher
35
56
  # construction) — a process that's been running has no reason to
36
57
  # re-walk its own already-current `saga_instances`.
58
+ #
59
+ # BOOT-TIME-ONLY (OR ITS TEST-RUNNER EQUIVALENT) — this method's
60
+ # only callers are `Loader.run_boot_gates!` (single-threaded, before
61
+ # `dispatcher_for` ever exists) and `Registry#reset_runtime_state!`
62
+ # (single-threaded test runner — see that method's own comment).
63
+ # Never called from live dispatch, so `@saga_instances` mutation
64
+ # here has no concurrent caller to race, unlike its OTHER two write
65
+ # points inside `@saga_mutex.synchronize` blocks (`saga_interpreter.
66
+ # rb`), which genuinely do and are guarded accordingly.
67
+ # rubocop:disable-next Hecks/ThreadSharedIvarMutation
37
68
  def rehydrate_sagas!
38
69
  @hecksagons.each_key do |domain|
39
- saga_persistence(domain).each_saga do |process_manager, correlation, state, memory|
40
- @saga_instances[process_manager][correlation] = { state: state, memory: memory }
70
+ saga_persistence(domain).each_saga do |process_manager, correlation, state, memory, completed_compensations = []|
71
+ pending = memory.delete(SAGA_PENDING_DISPATCH_KEY)
72
+ @saga_instances[process_manager][correlation] =
73
+ { state: state, memory: memory, completed_compensations: completed_compensations || [] }
74
+ warn_stalled_saga(domain, process_manager, correlation, state, pending) if pending
41
75
  end
42
76
  end
43
77
  self
@@ -45,6 +79,44 @@ module Hecks
45
79
 
46
80
  private
47
81
 
82
+ # THE OTHER HALF OF THE OUTBOX-SHAPED FIX (see saga_pending_
83
+ # dispatch.rb) — `SAGA_PENDING_DISPATCH_KEY`, if the crashed
84
+ # process left it standing, means the row's own `state` was
85
+ # checkpointed but the dispatch cascade that justifies it may
86
+ # never have run. Stripped out of `memory` before it becomes
87
+ # this instance's LIVE `:memory` (so nothing downstream — a
88
+ # `given`, a `with:` mapping, the fuzzer — ever sees it), and
89
+ # surfaced loudly instead: a WARNING plus a `saga_log` entry,
90
+ # never an automatic redrive (see saga_pending_dispatch.rb for
91
+ # why redriving without idempotent delivery would be worse than
92
+ # the stall). This is real, durable crash-recovery VISIBILITY —
93
+ # still not the reconciliation itself, which stays a human's
94
+ # call until hecks has idempotent redelivery to make it safe.
95
+ # rubocop:disable-next Hecks/ThreadSharedIvarMutation -- same
96
+ # justification as `rehydrate_sagas!`'s own disable comment
97
+ # above: this method's ONLY caller is that boot-time-only walk,
98
+ # never live dispatch, so `@saga_log` has no concurrent writer
99
+ # to race here.
100
+ def warn_stalled_saga(domain, process_manager, correlation, state, pending)
101
+ # `.transform_keys(&:to_sym)` — Heki's own `each_saga` only
102
+ # symbolizes `memory`'s TOP-level keys (`SagaStore#each_saga`'s
103
+ # own `transform_keys`, one level deep); a value NESTED under
104
+ # one of those keys, like this marker, comes back with plain
105
+ # string keys from Heki specifically, symbol keys already from
106
+ # Postgres/SQLite/D1's own `symbolize_names: true` parse. Normalizing
107
+ # here, once, is simpler than teaching every adapter's own
108
+ # shallow/deep parsing convention about this one reserved key.
109
+ pending = pending.transform_keys(&:to_sym)
110
+ dispatches = Array(pending[:dispatches]).join(", ")
111
+ warn "[hecks] #{domain} rehydrated #{process_manager} instance #{correlation.inspect} in state " \
112
+ "#{state.inspect} with a dispatch left pending from before the last crash/restart — " \
113
+ "#{dispatches} (on #{pending[:on].inspect}, #{pending[:from].inspect} -> #{pending[:to].inspect}) " \
114
+ "may or may not have actually run. hecks does not auto-redrive a pending saga dispatch (that " \
115
+ "needs idempotent delivery, which this pipeline doesn't have yet); reconcile this instance by hand."
116
+ @saga_log << { process_manager: process_manager, instance: correlation, rehydrated_stalled: true,
117
+ state: state, pending: pending }
118
+ end
119
+
48
120
  def resolve_saga_persistence(domain)
49
121
  anchor = hecksagon(domain) && bluebook(domain)&.aggregates&.first
50
122
  return Ports::Persistence::NULL_SAGA_STORE unless anchor
@@ -12,6 +12,7 @@ module Hecks
12
12
  module Verification
13
13
  def verify!
14
14
  verify_default_adapter!
15
+ verify_singleton_port_answers!
15
16
 
16
17
  @hecksagons.each_value do |hexagon|
17
18
  refuse_ungoverned_roles!(hexagon)
@@ -42,6 +43,8 @@ module Hecks
42
43
 
43
44
  repository(hexagon.domain, aggregate)
44
45
  end
46
+
47
+ warn_undurable_sagas!(hexagon)
45
48
  end
46
49
  self
47
50
  end
@@ -66,6 +69,7 @@ module Hecks
66
69
 
67
70
  def check_verb(bind)
68
71
  port = port_for(bind)
72
+ check_answers(port, bind.adapter)
69
73
  return if port.verb.to_s == bind.verb.to_s
70
74
 
71
75
  raise WiringError,
@@ -73,6 +77,55 @@ module Hecks
73
77
  "and cannot satisfy #{bind.verb}"
74
78
  end
75
79
 
80
+ # THE METHOD CONTRACT A `.port` FILE'S `verb`/`signal` NEVER
81
+ # CARRIED — an adapter can name the right port, satisfy the right
82
+ # verb, and admit every `.world` setting `check_settings` checks,
83
+ # and still be missing the one method a live dispatch will
84
+ # actually call. `answers` is optional per port (an empty list is
85
+ # today's pre-existing behavior, unchecked), so this only ever
86
+ # tightens a port that opted in.
87
+ def check_answers(port, adapter_name)
88
+ answers = Array(port.answers)
89
+ return if answers.empty?
90
+
91
+ klass = adapter_class(adapter_name)
92
+ missing = answers.reject { |method_name| klass.respond_to?(method_name) }
93
+ return if missing.empty?
94
+
95
+ raise WiringError,
96
+ "#{adapter_name} declares the #{port.name} port but does not respond to " \
97
+ "#{missing.map(&:inspect).join(', ')} — #{port.name}.port declares answers " \
98
+ "#{answers.map(&:inspect).join(', ')}"
99
+ end
100
+
101
+ # THE NINE SINGLETON PORTS' OWN GAP — `persistence`, `projection`
102
+ # and `loading` are per-aggregate bindings, checked above through
103
+ # every real `bind` a hexagon declares; a singleton port
104
+ # (`clock`, `authorization`, …) is never bound to an aggregate at
105
+ # all, so nothing above ever resolves one and nothing above ever
106
+ # ran `check_answers` against it. Each one's own `Ports::*.adapter`
107
+ # already refuses zero or multiple implementations, live, at
108
+ # first dispatch — that stays exactly as-is here (0 or 2+ is
109
+ # ambiguity, not a method-contract question, and asserting every
110
+ # declared port MUST have exactly one adapter would wrongly
111
+ # refuse a boot that simply never wires a port it doesn't use).
112
+ # This only ever tightens the ONE case those checks don't cover:
113
+ # exactly one adapter, wired, missing a method `answers` names.
114
+ PER_AGGREGATE_PORTS = %w[persistence projection loading].freeze
115
+
116
+ def verify_singleton_port_answers!
117
+ @ports.each_value do |port|
118
+ next if PER_AGGREGATE_PORTS.include?(port.name)
119
+ next if Array(port.answers).empty?
120
+
121
+ implementations = @adapters.values.select { |a| a.port == port.name }
122
+ next unless implementations.size == 1
123
+
124
+ check_answers(port, implementations.first.name)
125
+ end
126
+ self
127
+ end
128
+
76
129
  def check_settings(bind, settings)
77
130
  adapter = @adapters[bind.adapter]
78
131
  return unless adapter
@@ -152,6 +205,41 @@ module Hecks
152
205
  def commands_in(bluebook_ir)
153
206
  bluebook_ir.aggregates.flat_map { |aggregate| aggregate.commands + aggregate.entities.flat_map(&:commands) }
154
207
  end
208
+
209
+ # A domain that declares a `process_manager` but whose
210
+ # `saga_persistence` resolves to `NULL_SAGA_STORE` (no anchor
211
+ # aggregate, a RemoteRuntime-shaped adapter, an adapter that
212
+ # doesn't `respond_to?(:save_saga)`, or a rescued WiringError —
213
+ # see `SagaPersistence#resolve_saga_persistence`) gets sagas that
214
+ # advance correctly in-process and vanish on restart: no
215
+ # checkpoint written, nothing for `rehydrate_sagas!` to find, no
216
+ # compensation ever replayed. That is silent right up until the
217
+ # process actually dies mid-saga — the same "consistency/
218
+ # freshness defect applied to access control, failing open" ADR
219
+ # 0025 named for an unchecked `role`, here applied to saga
220
+ # durability instead.
221
+ #
222
+ # A WARNING, NOT A REFUSAL — unlike `refuse_ungoverned_roles!`,
223
+ # running sagas on a store with no `save_saga` is legitimate on
224
+ # purpose in a fast in-memory test/dev boot (this project's own
225
+ # `saga_durability_spec.rb` boots a process manager on `Memory`
226
+ # specifically to exercise the saga_mutex without real I/O), so
227
+ # refusing the boot outright would break a choice an author made
228
+ # deliberately. What a deploy needs is for the gap to be loud and
229
+ # undeniable, not for local dev/test to become impossible.
230
+ def warn_undurable_sagas!(hexagon)
231
+ bluebook_ir = bluebook(hexagon.domain)
232
+ return unless bluebook_ir
233
+ return if bluebook_ir.process_managers.empty?
234
+ return unless saga_persistence(hexagon.domain).equal?(Ports::Persistence::NULL_SAGA_STORE)
235
+
236
+ names = bluebook_ir.process_managers.map(&:name).join(", ")
237
+ warn "[hecks] #{hexagon.domain} declares process_manager(s) #{names} but its resolved " \
238
+ "persistence adapter has no save_saga — saga state advances correctly in-process " \
239
+ "and is LOST on restart (no checkpoint, no rehydration, no compensation replay). " \
240
+ "Bind this domain to an adapter that implements save_saga if this process_manager " \
241
+ "must survive a crash."
242
+ end
155
243
  end
156
244
  end
157
245
  end
@@ -45,9 +45,11 @@ module Hecks
45
45
  # GUARDS `saga_instances`' OWN mutation+checkpoint sequence
46
46
  # (`SagaInterpreter`'s 4 write points, §7) — the same shape of
47
47
  # hazard this codebase's own prior audit already flagged for
48
- # `@reaction_depth`, a thread-shared dispatcher ivar with no
49
- # lock, made meaningfully easier to hit once a persistence write
50
- # sits in the same critical section. Held across the in-memory
48
+ # `Dispatcher#reenter`'s reaction-depth counter (M20: a
49
+ # thread-shared ivar with no lock, since fixed by moving it to
50
+ # `Thread.current`, dispatcher.rb), made meaningfully easier to
51
+ # hit here once a persistence write sits in the same critical
52
+ # section. Held across the in-memory
51
53
  # mutation AND the checkpoint write together, never across a
52
54
  # saga's own dispatch cascade — see `SagaInterpreter#advance_saga`'s
53
55
  # own comment for why that distinction matters (non-reentrant
@@ -56,6 +58,35 @@ module Hecks
56
58
  @repositories = {}
57
59
  @projection_repositories = {}
58
60
  @bluebook_builders = {}
61
+ # EAGER, NOT LAZY — see `#resolved_eras`'s own comment for why. Built
62
+ # here rather than `@resolved_eras ||= {}` on first access so there is
63
+ # no window, post-boot, where two concurrently dispatching threads
64
+ # could race creating this Hash (Hecks/ThreadSharedIvarMutation; the
65
+ # same shape of hazard `Dispatcher#reenter`'s `@reaction_depth` was
66
+ # fixed for). Every WRITE into it still only ever happens at boot,
67
+ # single-threaded (`EraResolver.check!`, a `:pre_verify` boot gate) —
68
+ # this only removes the race on standing up the container itself for
69
+ # a boot with no era-plugin domain at all, whose first touch would
70
+ # otherwise be a live dispatch's own `RepositoryFactory.build` read.
71
+ @resolved_eras = {}
72
+ # EAGER, NOT LAZY — see `#capability_graph`'s own comment for why.
73
+ # `CapabilityGraph.new` only stores the registry reference; there is
74
+ # no reason to defer it, and doing so removes the exact same
75
+ # first-access race `#resolved_eras` above does, while preserving the
76
+ # "same instance every call" identity `spec/runtime/capability_graph_
77
+ # spec.rb` already requires.
78
+ @capability_graph = CapabilityGraph.new(self)
79
+ # `@saga_persistence` itself is eager (see `#saga_persistence`'s own
80
+ # comment) — only the PER-DOMAIN resolution inside it is genuinely
81
+ # expensive and lazy, guarded by this dedicated mutex. NOT the same
82
+ # mutex as `@saga_mutex`: `checkpoint` (saga_interpreter.rb) calls
83
+ # `saga_persistence(domain)` from INSIDE an `@saga_mutex.synchronize`
84
+ # block, so reusing `@saga_mutex` here would deadlock the very first
85
+ # time a saga advanced (a `Mutex` is not reentrant — the exact
86
+ # warning `@saga_mutex`'s own comment already gives for a different
87
+ # reason).
88
+ @saga_persistence = {}
89
+ @saga_persistence_mutex = Mutex.new
59
90
  end
60
91
 
61
92
  # THE BUILDER STAYS OPEN FOR THE LIFE OF THIS REGISTRY, keyed by chapter
@@ -67,6 +98,20 @@ module Hecks
67
98
  @bluebook_builders[name.to_s] ||= yield
68
99
  end
69
100
 
101
+ # BOOT-TIME-ONLY, SINGLE-THREADED — every `add_*` below (through
102
+ # `add_translation`) is called exclusively from `Hecks.collect`
103
+ # (hecks.rb), which is what `Hecks.bluebook`/`.hecksagon`/`.port`/
104
+ # `.adapter`/`.world`/`.translation` run inside while a `.bluebook`/
105
+ # `.hecksagon`/`.world` file is being `Kernel.load`ed — i.e. strictly
106
+ # during `Loader.boot`/`.boot_files`, before `dispatcher_for` ever
107
+ # hands this registry to a live, multi-threaded caller. Nothing
108
+ # downstream of boot ever calls these — verified by grepping every
109
+ # call site in lib/ and spec/ before writing this — so unlike
110
+ # `#resolved_eras`/`#capability_graph`/`#saga_persistence` (each
111
+ # reachable from live dispatch, and fixed for real above/in
112
+ # registry/saga_persistence.rb) there is no concurrent caller for
113
+ # `Hecks/ThreadSharedIvarMutation` to actually be warning about here.
114
+ # rubocop:disable Hecks/ThreadSharedIvarMutation
70
115
  def add_bluebook(item) = @bluebooks[item.name] = item
71
116
 
72
117
  # MERGED, NOT REPLACED — RECOVERED, not new (see Runtime::Loader
@@ -99,12 +144,16 @@ module Hecks
99
144
  end
100
145
 
101
146
  def add_translation(item) = @translations << item
147
+ # rubocop:enable Hecks/ThreadSharedIvarMutation
102
148
 
103
149
  # {domain name => era ordinal} as resolved by the boot-time era
104
150
  # gate. A lineage adapter writes into ITS OWN era's partition —
105
151
  # which, for an old checkout booting a held-but-superseded shape,
106
- # is not the newest one.
107
- def resolved_eras = @resolved_eras ||= {}
152
+ # is not the newest one. The Hash itself is stood up in `initialize`
153
+ # (see that comment) this is a plain reader, not a memoizer;
154
+ # `Hecks/ThreadSharedIvarMutation` is the reason there is no `||=`
155
+ # left here to flag.
156
+ attr_reader :resolved_eras
108
157
 
109
158
  def bluebook(name) = @bluebooks[name.to_s]
110
159
  def hecksagon(name) = @hecksagons[name.to_s]
@@ -133,6 +182,16 @@ module Hecks
133
182
  # test to get isolation (`Behaviors::Expectations.run_one`) — ~2s a
134
183
  # boot, 76 chess behaviours = two and a half minutes of booting the
135
184
  # same two files — can now boot once and reset between tests.
185
+ #
186
+ # SINGLE-THREADED CALLER, THE SAME REASON THE `add_*` CLUSTER ABOVE
187
+ # IS EXEMPT — `Behaviors::Expectations.run_one` is this method's ONLY
188
+ # caller (verified by grep before writing this), and it runs one
189
+ # test at a time: `Runner#run` maps over tests sequentially, and
190
+ # `Behaviors.rspec`'s generated examples run under RSpec's own
191
+ # single-threaded example loop. No production dispatch path calls
192
+ # this at all — a live Puma worker pool never resets a registry out
193
+ # from under itself mid-flight.
194
+ # rubocop:disable-next Hecks/ThreadSharedIvarMutation
136
195
  def reset_runtime_state!
137
196
  @event_log.clear
138
197
  @reaction_log.clear
@@ -146,9 +205,11 @@ module Hecks
146
205
  self
147
206
  end
148
207
 
149
- def capability_graph
150
- @capability_graph ||= CapabilityGraph.new(self)
151
- end
208
+ # Built eagerly in `initialize` (see that comment) — this is a plain
209
+ # reader, not a memoizer; `spec/runtime/capability_graph_spec.rb`
210
+ # asserts the SAME instance comes back every call, which this still
211
+ # gives, just without a lazy `||=` race on standing it up.
212
+ attr_reader :capability_graph
152
213
 
153
214
  def read_repository(domain, aggregate)
154
215
  key = [domain.to_s, aggregate.hecks_name]