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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +132 -5
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/value_object.rb +9 -1
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +97 -13
- data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
- data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +14 -0
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +85 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +7 -2
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +48 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
104
|
-
instance
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
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?
|
|
463
|
+
next if given.nil?
|
|
428
464
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|