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
@@ -4,6 +4,7 @@ require_relative "../bluebook/process_manager"
4
4
  require_relative "errors"
5
5
  require_relative "reaction_invocation"
6
6
  require_relative "value"
7
+ require_relative "saga_pending_dispatch"
7
8
 
8
9
  module Hecks
9
10
  module Runtime
@@ -53,13 +54,34 @@ module Hecks
53
54
  # `unwind` go on to mutate in place; round-tripping through JSON
54
55
  # is also what guarantees the value is safe for every adapter that
55
56
  # itself calls `JSON.generate` on it.
56
- def checkpoint(pm, correlation, instance, domain)
57
+ # `pending:` see saga_pending_dispatch.rb. Injected into the
58
+ # WRITTEN copy of memory only, never into `instance[:memory]`
59
+ # itself: every other reader of a live instance's memory
60
+ # (`dispatch_args`'s "opening event memory" scope, the fuzzer's
61
+ # own round-trip/shape checks, `saga_spec.rb`'s exact-equality
62
+ # assertion against a fresh instance's seeded memory) sees exactly
63
+ # what it always did. The marker exists ONLY in the persisted
64
+ # blob, and only for as long as a dispatch cascade is genuinely
65
+ # in flight for this instance.
66
+ def checkpoint(pm, correlation, instance, domain, pending: nil)
67
+ memory = deep_copy(instance[:memory])
68
+ memory[SAGA_PENDING_DISPATCH_KEY] = pending if pending
57
69
  @registry.saga_persistence(domain).save_saga(
58
70
  process_manager: pm.name, correlation: correlation,
59
- state: instance[:state], memory: deep_copy(instance[:memory])
71
+ state: instance[:state], memory: memory,
72
+ completed_compensations: deep_copy_array(instance[:completed_compensations])
60
73
  )
61
74
  end
62
75
 
76
+ # `deep_copy` is `JSON.parse(JSON.generate(hash), ...)`, which
77
+ # only accepts an OBJECT at the top level — `completed_compensations`
78
+ # is an ARRAY, so it gets its own wrap-and-unwrap rather than a
79
+ # second, parallel `deep_copy_array` reimplementing the same
80
+ # round-trip. `|| []` — an instance from before this field existed
81
+ # (or one that has never completed a compensable leg) rehydrates
82
+ # to an empty ledger, never nil.
83
+ def deep_copy_array(array) = deep_copy(list: array || [])[:list]
84
+
63
85
  def deep_copy(hash) = JSON.parse(JSON.generate(hash), symbolize_names: true)
64
86
 
65
87
  def begin_saga(pm, event, domain)
@@ -75,7 +97,23 @@ module Hecks
75
97
  created = @registry.saga_mutex.synchronize do
76
98
  next false if @registry.saga_instances[pm.name].key?(correlation)
77
99
 
78
- instance = { state: pm.states.first, memory: event.payload }
100
+ # `.dup`, NOT THE SAME OBJECT — a fresh saga's own memory starts
101
+ # as a COPY of the starting event's own payload, never the
102
+ # payload itself. A saga's own memory is meant to be written
103
+ # into over its lifetime (remember-style, growing beyond what
104
+ # the starting event carried) ; the payload it was seeded from
105
+ # is a fact about something that ALREADY happened, logged and
106
+ # emitted before the saga ever saw it. Sharing the one Hash
107
+ # object between them means a write into the saga's own memory
108
+ # is silently ALSO a write into an already-emitted event's own
109
+ # payload — retroactively adding a field nothing announced.
110
+ # `event.payload` is deep-frozen by `Event#emit!` by the time
111
+ # this runs, so a naive in-place write here would raise
112
+ # FrozenError rather than corrupt silently — but the `.dup`
113
+ # still matters: it is what makes the saga's own memory a
114
+ # normal, writable Hash of its own, rather than one write away
115
+ # from crashing every future in-place `remember`.
116
+ instance = { state: pm.states.first, memory: event.payload.dup, completed_compensations: [] }
79
117
  @registry.saga_instances[pm.name][correlation] = instance
80
118
  checkpoint(pm, correlation, instance, domain)
81
119
  true
@@ -100,8 +138,9 @@ module Hecks
100
138
  correlation = saga_correlation(pm, event)
101
139
  return if correlation.to_s.empty?
102
140
 
103
- record = { process_manager: pm.name, on: event.name, instance: correlation }
104
- instance = nil
141
+ record = { process_manager: pm.name, on: event.name, instance: correlation }
142
+ instance = nil
143
+ pre_state = nil
105
144
 
106
145
  advanced = @registry.saga_mutex.synchronize do
107
146
  instance = @registry.saga_instances[pm.name][correlation]
@@ -115,17 +154,87 @@ module Hecks
115
154
  next false
116
155
  end
117
156
 
157
+ pre_state = instance[:state]
118
158
  instance[:state] = handler.to_state
119
- checkpoint(pm, correlation, instance, domain)
159
+ checkpoint(pm, correlation, instance, domain,
160
+ pending: pending_marker(event, handler, pre_state, instance[:state]))
120
161
  true
121
162
  end
122
163
  return unless advanced
123
164
 
124
- @registry.saga_log << record.merge(advanced: true, from: handler.from_state, to: handler.to_state)
165
+ settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state)
166
+ end
167
+
168
+ def pending_marker(event, handler, from_state, to_state)
169
+ { on: event.name, from: from_state, to: to_state, dispatches: handler.dispatches.map(&:command_name) }
170
+ end
171
+
172
+ # THE SHARED TAIL of `advance_saga` and `unwind` — both are "guard,
173
+ # mutate, checkpoint-with-pending" under the mutex (kept separate
174
+ # per caller: `advance_saga`'s own guard also has to handle "no
175
+ # instance at all", `unwind`'s doesn't), then this: log the real
176
+ # observed transition, run the leg's dispatches, and clear the
177
+ # 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)
179
+ # `from:`/`to:` are the INSTANCE'S OWN real pre/post state — read
180
+ # back from `instance` itself, never re-derived from `handler.
181
+ # from_state`/`handler.to_state` a second time. `Properties.saga_
182
+ # advances_follow_declared_handlers` (fuzzing/properties.rb) builds
183
+ # its OWN "declared edges" list from this SAME handler object (via
184
+ # `pm.handlers`), so a log entry that just echoed `handler.
185
+ # from_state`/`handler.to_state` back could never disagree with
186
+ # that list no matter what the runtime actually did — the entry
187
+ # and the thing it's checked against would be the identical fact,
188
+ # read twice. Logging the instance's own observed state instead
189
+ # means a future defect that moves an instance somewhere its own
190
+ # declared handler didn't say (a stale handler reference, the
191
+ # wrong handler picked, a second racing mutation) shows up as a
192
+ # real mismatch instead of vanishing into a tautology.
193
+ @registry.saga_log << record.merge(advanced: true, from: pre_state, to: instance[:state])
194
+
195
+ # DERIVED COMPENSATION FIRST, NEWEST-FIRST — only for `unwind`'s
196
+ # own call (`drain_compensations: true`): every leg THIS INSTANCE
197
+ # actually completed that declared its own `compensates`, popped
198
+ # and dispatched in reverse completion order, BEFORE any
199
+ # hand-written `on :refused` dispatches below — coexistence, not
200
+ # replacement. Drained (not just read) as it fires: a saga's own
201
+ # `on :refused` handler is guarded against re-entry by `unwind`'s
202
+ # own `instance[:state] == handler.from_state` check, so this can
203
+ # only ever run once per refusal — but draining rather than
204
+ # leaving the ledger populated is what makes that true by
205
+ # construction too, not only by the state guard.
206
+ if drain_compensations
207
+ compensations = instance[:completed_compensations] || []
208
+ deliver_derived_compensation(pm, compensations.pop, correlation, domain) until compensations.empty?
209
+ checkpoint(pm, correlation, instance, domain)
210
+ end
125
211
 
126
212
  handler.dispatches.each do |spec|
127
213
  deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
128
214
  end
215
+
216
+ # THE CLEAR — guarded by the SAME identity check `end_saga`'s own
217
+ # `.delete` return value implies: `deliver_saga_dispatch`'s
218
+ # `@door.reenter` can synchronously trigger this SAME correlation's
219
+ # `ends_on` event as a nested reaction (a leg's own dispatch is
220
+ # what makes the saga's terminal event fire), which deletes this
221
+ # row from the store before this line ever runs. Writing the
222
+ # clear unconditionally would RESURRECT a legitimately-ended saga
223
+ # — this diff's own first attempt did exactly that, caught by
224
+ # `saga_durability_spec.rb`'s "deletes the checkpoint once a saga
225
+ # genuinely ends" — so this only re-checkpoints when `instance`
226
+ # is still THE SAME object `@saga_instances` holds for this
227
+ # correlation (`.equal?`, not `==`: a fresh saga reborn under the
228
+ # same correlation between then and now is a DIFFERENT instance,
229
+ # and writing this stale one's state onto that one's row would be
230
+ # its own corruption). Under the mutex — dispatching is over by
231
+ # now, so this is not the reentrancy hazard `advance_saga`'s own
232
+ # comment warns about.
233
+ @registry.saga_mutex.synchronize do
234
+ next unless @registry.saga_instances[pm.name][correlation].equal?(instance)
235
+
236
+ checkpoint(pm, correlation, instance, domain, pending: nil)
237
+ end
129
238
  end
130
239
 
131
240
  def deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
@@ -166,7 +275,29 @@ module Hecks
166
275
  end
167
276
 
168
277
  attempt = 0
278
+ compensation_recorded = false
169
279
  begin
280
+ # RECORDED BEFORE DISPATCHING, not after `@door.reenter`
281
+ # returns — `@door.reenter` can recursively RE-ENTER THIS SAME
282
+ # saga interpreter (the event THIS dispatch emits triggers a
283
+ # LATER handler, which can itself refuse and unwind) entirely
284
+ # WITHIN this one call, before it ever returns here. Recording
285
+ # "after reenter succeeds" would be too late for a NESTED
286
+ # refusal to ever see this leg's own compensation — found
287
+ # live: Settlement's own AccountDebited handler refuses
288
+ # Account.Credit and unwinds from INSIDE Account.Debit's own
289
+ # `reenter` call, so "delivered: true, then record" left the
290
+ # ledger empty at the exact moment it was needed. Popped back
291
+ # off in the rescues below if THIS leg's own attempt is the
292
+ # one that failed — never left recorded for a refusal that
293
+ # was never this leg's own to compensate.
294
+ if spec.compensates && !compensation_recorded
295
+ resolved = dispatch_args(pm, spec.compensates, event, instance, correlation)
296
+ instance[:completed_compensations] << { command_name: spec.compensates.command_name, args: resolved }
297
+ checkpoint(pm, correlation, instance, domain)
298
+ compensation_recorded = true
299
+ end
300
+
170
301
  invocation = ReactionInvocation.build(
171
302
  registry: @registry,
172
303
  verb: qualified(spec.command_name, domain),
@@ -179,6 +310,7 @@ module Hecks
179
310
  saga_correlation: { pm.correlation_head.to_s => correlation }, **invocation)
180
311
  @registry.saga_log << record.merge(delivered: true)
181
312
  rescue *DOMAIN_REFUSALS => error
313
+ unrecord_compensation(instance, correlation, domain, pm) if compensation_recorded
182
314
  # Same rule as the policy interpreter : a refusal by the target is
183
315
  # a recorded outcome, and the leg that raised it UNWINDS — see
184
316
  # `unwind`'s own comment for why the procedure runs its
@@ -187,6 +319,8 @@ module Hecks
187
319
  @registry.saga_log << record.merge(delivered: false, reason: error.message)
188
320
  unwind(pm, event, instance, correlation, domain)
189
321
  rescue StandardError => error
322
+ unrecord_compensation(instance, correlation, domain, pm) if compensation_recorded
323
+ compensation_recorded = false
190
324
  # A DEFECT, not a refusal — see PolicyInterpreter#deliver's own
191
325
  # comment for the full reasoning: the same DOMAIN_REFUSALS split,
192
326
  # and the same "the triggering command already succeeded and
@@ -222,6 +356,22 @@ module Hecks
222
356
  end
223
357
  end
224
358
 
359
+ # THE ROLLBACK HALF of `deliver_saga_dispatch`'s own speculative
360
+ # pre-record (that method's own comment for why it has to be
361
+ # speculative) — THIS leg's own attempt is the one that failed,
362
+ # so whatever was just pushed for it was never actually earned.
363
+ # `.pop`, not a search-and-delete: nothing else can have pushed
364
+ # AFTER this leg's own entry without this leg's own `@door.
365
+ # reenter` call having already returned (the recursive re-entry
366
+ # this whole mechanism exists for only ever runs BETWEEN this
367
+ # push and this leg's own return, and a nested refusal that
368
+ # consumed it already popped it itself — this rollback only ever
369
+ # runs for THIS leg's own, still-present entry).
370
+ def unrecord_compensation(instance, correlation, domain, pm)
371
+ instance[:completed_compensations].pop
372
+ checkpoint(pm, correlation, instance, domain)
373
+ end
374
+
225
375
  # A refused leg UNWINDS — the procedure runs the leg declared `on :refused`,
226
376
  # which is where the compensation lives. So does a leg that hit the
227
377
  # reaction-depth ceiling, and so does a leg that crashed and stayed
@@ -231,7 +381,7 @@ module Hecks
231
381
  # Until this existed a refusal was RECORDED and nothing else happened. The
232
382
  # wire's thousand was taken from the source, refused by the destination, and
233
383
  # sat nowhere until a human dispatched the reversal by hand ; banking's
234
- # settlement left a debit standing with no credit and no reversal at all.
384
+ # settlement left a debit standing with no credit and no compensation at all.
235
385
  # Both bluebooks had written the compensating leg. Nothing armed it.
236
386
  #
237
387
  # A compensation that is itself refused does NOT unwind again, and needs no
@@ -242,7 +392,8 @@ module Hecks
242
392
  handler = pm.handler_for(REFUSED)
243
393
  return unless handler && instance
244
394
 
245
- record = { process_manager: pm.name, on: REFUSED, instance: correlation }
395
+ record = { process_manager: pm.name, on: REFUSED, instance: correlation }
396
+ pre_state = nil
246
397
 
247
398
  # Same non-reentrancy reasoning as `advance_saga`'s own comment —
248
399
  # the mutex covers only the check-and-mutate-and-checkpoint step.
@@ -253,16 +404,67 @@ module Hecks
253
404
  next false
254
405
  end
255
406
 
407
+ pre_state = instance[:state]
256
408
  instance[:state] = handler.to_state
257
- checkpoint(pm, correlation, instance, domain)
409
+ checkpoint(pm, correlation, instance, domain,
410
+ pending: pending_marker(event, handler, pre_state, instance[:state]))
258
411
  true
259
412
  end
260
413
  return unless advanced
261
414
 
262
- @registry.saga_log << record.merge(advanced: true, from: handler.from_state, to: handler.to_state)
415
+ # See `settle_transition`'s own comment on `pre_state`/
416
+ # `instance[:state]` — the real observed transition, not a
417
+ # second read of the SAME handler object `Properties.saga_
418
+ # advances_follow_declared_handlers` checks this log against.
419
+ # `drain_compensations: true` — only `unwind`'s own call site
420
+ # fires derived compensation; `advance_saga`'s own call never
421
+ # does.
422
+ settle_transition(pm, event, handler, instance, correlation, domain, record, pre_state,
423
+ drain_compensations: true)
424
+ end
263
425
 
264
- handler.dispatches.each do |spec|
265
- deliver_saga_dispatch(pm, spec, event, instance, correlation, domain)
426
+ # A DERIVED COMPENSATION — `entry[:args]` is already resolved
427
+ # (`record_completed_compensation`'s own comment for why), so this
428
+ # skips `dispatch_args` entirely and goes straight to delivery,
429
+ # through the SAME retry-on-defect path an ordinary forward leg
430
+ # uses. Never re-enters `unwind` on its own failure — a
431
+ # compensation that itself refuses is a real, pre-existing gap
432
+ # this feature makes visible rather than closes (see this file's
433
+ # own class-level notes); `compensation_failed: true` tags it
434
+ # distinctly in the log instead of recording it identically to an
435
+ # ordinary failed delivery, and every OTHER completed compensation
436
+ # 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] }
439
+
440
+ attempt = 0
441
+ begin
442
+ invocation = ReactionInvocation.build(
443
+ registry: @registry,
444
+ verb: qualified(entry[:command_name], domain),
445
+ projected: entry[:args],
446
+ explicit: true,
447
+ passthrough: [pm.correlation_head],
448
+ source_receiver: nil
449
+ )
450
+ @door.reenter(qualified(entry[:command_name], domain),
451
+ saga_correlation: { pm.correlation_head.to_s => correlation }, **invocation)
452
+ @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
456
+ attempt += 1
457
+ 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,
460
+ attempt: attempt, retrying: true)
461
+ retry
462
+ end
463
+
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)
266
468
  end
267
469
  end
268
470
 
@@ -0,0 +1,45 @@
1
+ module Hecks
2
+ module Runtime
3
+ # THE ONE SHARED CONSTANT between `SagaInterpreter` (the writer) and
4
+ # `Registry::SagaPersistence#rehydrate_sagas!` (the reader) for a
5
+ # scoped, minimal answer to the saga-durability review's item 8 (a
6
+ # durable outbox): a marker that survives exactly the window a crash
7
+ # in `advance_saga`/`unwind` can otherwise hide.
8
+ #
9
+ # THE PROBLEM THIS CLOSES — `checkpoint` persists a saga's new state
10
+ # BEFORE the leg that justifies it (`handler.dispatches`) runs, and
11
+ # deliberately so: the mutex it holds is not reentrant, and a
12
+ # dispatch can re-enter this same interpreter. If the process dies
13
+ # in that window, the store says the transition happened and there
14
+ # is no record that its dispatch(es) never ran — not a refusal (the
15
+ # domain never got asked), not a defect (nothing raised), just
16
+ # silence indistinguishable from a leg that finished cleanly.
17
+ #
18
+ # THE FIX — `checkpoint` now writes this key into the SAME already-
19
+ # durable `memory` blob (no new column, no adapter/schema change:
20
+ # `memory` is already an opaque, adapter-agnostic JSON blob every
21
+ # `save_saga` implementation round-trips verbatim) whenever it
22
+ # checkpoints a state a dispatch cascade hasn't run for YET, and
23
+ # clears it (a second checkpoint, `pending: nil`) once that cascade
24
+ # — success, refusal-compensated, defect-compensated, or ceiling-
25
+ # compensated — has actually run. A crash between those two writes
26
+ # leaves the marker standing; `rehydrate_sagas!` strips it back out
27
+ # of the LIVE instance's own `:memory` (so no dispatch/`given`/
28
+ # fuzzer/doc consumer of a saga's memory ever sees this key — it
29
+ # exists only in the persisted blob) and surfaces it loudly instead.
30
+ #
31
+ # WHAT THIS DELIBERATELY DOES NOT DO — auto-redrive the pending leg.
32
+ # Redelivering a dispatch whose outcome is genuinely unknown is only
33
+ # safe with idempotent delivery (the downstream command recognizing
34
+ # and no-op'ing a duplicate), which hecks's command/event pipeline
35
+ # has no mechanism for today. Blindly re-dispatching without that is
36
+ # how a stalled transfer becomes a DOUBLE-CREDITED one — a strictly
37
+ # worse defect than the stall it would replace. So this is real,
38
+ # durable, crash-surviving VISIBILITY into exactly what a stalled
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.
43
+ SAGA_PENDING_DISPATCH_KEY = :__hecks_saga_pending_dispatch__
44
+ end
45
+ end
@@ -14,7 +14,7 @@ module Hecks
14
14
  discriminant = value_object.attributes.first.name
15
15
  offered = fields[discriminant]
16
16
  admitted = value_object.members.map { |member| member[discriminant].to_s }
17
- return if admitted.include?(offered.to_s)
17
+ return if value_object.members.any? { |member| member_matches?(member, fields) }
18
18
 
19
19
  raise InvariantViolation,
20
20
  RefusalWording.render("InvariantViolation", "closed_set_member",
@@ -22,6 +22,24 @@ module Hecks
22
22
  admitted: admitted.map(&:inspect).join(", "), offered: offered.inspect)
23
23
  end
24
24
 
25
+ # EVERY DECLARED FIELD, not only the discriminant — a multi-column
26
+ # `member` row (`StatementFrequency`'s `cadence`/`retention_months`/
27
+ # `paper_fee_cents`, statements.bluebook) names a whole tuple, and a
28
+ # caller offering the right `cadence` with the wrong
29
+ # `retention_months` is not a genuine member just because the FIRST
30
+ # column happened to match. `admitted.include?(offered.to_s)` above
31
+ # used to be the entire check, so it stopped at that first column —
32
+ # confirmed live: `Value.build(StatementFrequency, cadence:
33
+ # "monthly", retention_months: 999, paper_fee_cents: 999)` was
34
+ # admitted outright, no member of the closed set declares that row.
35
+ # A single-field set (`AccountKind`, `LedgerDirection`, ...) has
36
+ # exactly one key here, so this reduces to the original discriminant-
37
+ # only comparison for every set that only ever had one column to
38
+ # begin with — same refusal, same wording, unchanged.
39
+ private def member_matches?(member, fields)
40
+ member.all? { |field, value| fields[field].to_s == value.to_s }
41
+ end
42
+
25
43
  # THE SAME REFUSAL, FOR A SET NAMED SOMEWHERE ELSE.
26
44
  #
27
45
  # `admit_member` above refuses a non-member when the value object BEING
@@ -121,7 +121,13 @@ module Hecks
121
121
  segments = path.to_s.split(".")
122
122
  segments.shift if direct_head && segments.first == direct_head
123
123
  segments.reduce(materialized) do |held, segment|
124
- held.is_a?(Hash) ? (held[segment.to_sym] || held[segment]) : nil
124
+ next nil unless held.is_a?(Hash)
125
+
126
+ # `key?` decides which spelling answers, never `||` — a
127
+ # genuinely-held `false` must not fall through to the
128
+ # other spelling (usually absent) and read as `nil`.
129
+ sym = segment.to_sym
130
+ held.key?(sym) ? held[sym] : held[segment]
125
131
  end
126
132
  end
127
133
  return value if parts.any? { |part| part.nil? || (part.respond_to?(:empty?) && part.empty?) }
@@ -197,6 +203,20 @@ module Hecks
197
203
  # value_object(name)`, so callers with no aggregate in reach
198
204
  # (`Value#with`, always re-setting an already-scalar arithmetic
199
205
  # field) simply skip this and keep their prior behavior.
206
+ # RECURSES INTO EACH NESTED FIELD'S OWN VALIDATION TOO, not only its
207
+ # shape — found live alongside the shape bug this method's header
208
+ # already describes: a nested `Price`/`Size` (a value-object-typed
209
+ # field of ANOTHER value object, e.g. `Pizza.price_cents`,
210
+ # `Pizza.size`) had its Hash shape normalized here but never ran
211
+ # `validate!` — `build`, below, only ever validated the OUTER value
212
+ # object's own direct fields, so a negative `price_cents.cents` or an
213
+ # out-of-`one_of` `size.value` sailed through a `Pizza`-typed command
214
+ # argument untouched, while the exact same nested type declared as a
215
+ # direct, top-level command attribute (`SafeDepositBox.Rent`'s own
216
+ # `attribute :size, Size`) was already checked correctly. `apply_
217
+ # defaults` runs first, same as the outer value object gets in
218
+ # `build`, so a nested field's own default is filled in before its
219
+ # own invariants read it.
200
220
  def normalize_composite_fields(aggregate, value_object, fields)
201
221
  return fields unless aggregate&.respond_to?(:value_object)
202
222
 
@@ -209,17 +229,27 @@ module Hecks
209
229
  nested = value_object_for(aggregate, attribute.type)
210
230
  next unless nested
211
231
 
212
- fields[attribute.name] = normalize_composite_fields(aggregate, nested, fields_for(nested, attribute.name, raw))
232
+ nested_fields = apply_defaults(nested, fields_for(nested, attribute.name, raw))
233
+ nested_fields = normalize_composite_fields(aggregate, nested, nested_fields)
234
+ validate!(nested, nested_fields)
235
+ fields[attribute.name] = nested_fields
213
236
  end
214
237
 
215
238
  fields
216
239
  end
217
240
 
218
- def build(value_object, fields, aggregate = nil)
219
- fields = value_object.attributes.each_with_object(fields.transform_keys(&:to_sym)) do |attribute, completed|
241
+ def apply_defaults(value_object, fields)
242
+ value_object.attributes.each_with_object(fields) do |attribute, completed|
220
243
  completed[attribute.name] = attribute.default unless completed.key?(attribute.name) || attribute.default.nil?
221
244
  end
222
- fields = normalize_composite_fields(aggregate, value_object, fields)
245
+ end
246
+
247
+ # THE FULL DOOR A VALUE OBJECT'S OWN FIELDS PASS THROUGH — shared by
248
+ # `build` (the outer value object) and `normalize_composite_fields`
249
+ # (every nested one), so a nested `Price`/`Size` is refused exactly
250
+ # the same way, with exactly the same wording, as the identical type
251
+ # declared directly on a command.
252
+ def validate!(value_object, fields)
223
253
  admit_member(value_object, fields)
224
254
  check_admitted(value_object, fields)
225
255
  check_numeric_fields(value_object, fields)
@@ -233,6 +263,12 @@ module Hecks
233
263
  name: value_object.hecks_name, description: invariant.description,
234
264
  offered: canonical_fields(fields))
235
265
  end
266
+ end
267
+
268
+ def build(value_object, fields, aggregate = nil)
269
+ fields = apply_defaults(value_object, fields.transform_keys(&:to_sym))
270
+ fields = normalize_composite_fields(aggregate, value_object, fields)
271
+ validate!(value_object, fields)
236
272
  new(value_object, fields)
237
273
  end
238
274
 
@@ -424,12 +460,41 @@ module Hecks
424
460
  next unless expected
425
461
 
426
462
  given = fields[attribute.name]
427
- next if given.nil? || given.is_a?(expected)
463
+ next if given.nil?
428
464
 
429
- raise TypeMismatch,
430
- RefusalWording.render("TypeMismatch", "numeric_field",
431
- type: value_object.hecks_name, field: attribute.name,
432
- expected: attribute.type, offered: Rendering.describe(given))
465
+ unless given.is_a?(expected)
466
+ raise TypeMismatch,
467
+ RefusalWording.render("TypeMismatch", "numeric_field",
468
+ type: value_object.hecks_name, field: attribute.name,
469
+ expected: attribute.type, offered: Rendering.describe(given))
470
+ end
471
+
472
+ # PRD 05 (numeric-boundary-coverage) — `given.is_a?(expected)`
473
+ # alone waves a NaN or an Infinity straight through: both are
474
+ # real `Float`s, so `is_a?(Numeric)`/`is_a?(Float)` is true for
475
+ # either. Never exercised before this, because
476
+ # `ValueGenerator::FLOAT_EDGE_CASES` had no non-finite value in
477
+ # it — the fuzzer could not have found this on its own until
478
+ # the table was widened alongside this fix. Left unchecked, a
479
+ # non-finite Float reaches `CommandRules::Arithmetic#clamp`
480
+ # (`current.clamp(min, max)` — `ArgumentError: comparison of
481
+ # Float with X failed`, a genuine Ruby-level crash, not a
482
+ # domain refusal, exactly the same failure mode this method's
483
+ # own header describes for a mistyped field) or all the way to
484
+ # storage, where `JSON.generate`/`#to_json` raises
485
+ # `JSON::GeneratorError: NaN/Infinity not allowed in JSON` the
486
+ # moment anything tries to persist or replay it — again a raw
487
+ # crash, not a refusal. `-0.0` is deliberately NOT refused
488
+ # here: it IS finite, round-trips through JSON as `-0.0`
489
+ # cleanly (confirmed empirically), and is a legitimate,
490
+ # meaningful float value (a signed zero), not a corruption
491
+ # risk — only NaN and +/-Infinity are.
492
+ if given.is_a?(Float) && !given.finite?
493
+ raise TypeMismatch,
494
+ RefusalWording.render("TypeMismatch", "non_finite_field",
495
+ type: value_object.hecks_name, field: attribute.name,
496
+ offered: Rendering.describe(given))
497
+ end
433
498
  end
434
499
  end
435
500
 
data/lib/hecks/runtime.rb CHANGED
@@ -50,10 +50,6 @@ require_relative "runtime/policy_interpreter"
50
50
  require_relative "runtime/saga_interpreter"
51
51
  require_relative "runtime/dispatcher"
52
52
  require_relative "runtime/rebuild_sweep"
53
- require_relative "runtime/era_guard"
54
- require_relative "runtime/storage_shape"
55
- require_relative "runtime/era_tamper"
56
- require_relative "runtime/era_check"
57
53
  require_relative "runtime/tenant_check"
58
54
  require_relative "runtime/loader"
59
55
 
@@ -89,7 +85,23 @@ module Hecks
89
85
  # Bind the ambient caller (see Runtime::Caller) for the duration of
90
86
  # the block — who a command's declared `role`, if any, is checked
91
87
  # against.
92
- def as_caller(role:, actor_id: nil, &block) = Caller.as(role: role, actor_id: actor_id, &block)
88
+ #
89
+ # `as_of:` is OPTIONAL, same opt-in shape as `actor_id:` — a caller
90
+ # that wants a Governance `RoleAssignment`'s `starts_at` enforced
91
+ # passes `as_of: Ports::Clock.now(registry)` here, at the door,
92
+ # exactly where `cli_runner.rb` already merges `Clock.now` into a
93
+ # command's own args. Nothing on the dispatch path calls the clock
94
+ # itself — see `Ports::Clock`'s own header for why — so an unbound
95
+ # `as_of` leaves `starts_at` unchecked, exactly as before.
96
+ #
97
+ # `scope:` is OPTIONAL too — a caller that states which scope it is
98
+ # acting in gets that scope checked against the matching
99
+ # `RoleAssignment`'s own `scope`, not just its `role_name`. See
100
+ # `Runtime::Caller::Current`'s own header for why this lives here
101
+ # rather than as a command-level DSL construct.
102
+ def as_caller(role:, actor_id: nil, as_of: nil, scope: nil, &block)
103
+ Caller.as(role: role, actor_id: actor_id, as_of: as_of, scope: scope, &block)
104
+ end
93
105
  end
94
106
  end
95
107
  end