A command answers a synchronous caller directly. Much of what a real
system does is not synchronous — a payment gateway’s webhook fires, a
scheduled payment’s presentment gets declined at the far end, and
nothing is waiting for a direct answer. Something inside the domain
still has to react, and the reaction is a domain decision, not
infrastructure. policy is the seam where a fact that already happened
becomes that decision: not a script, not a callback bolted onto an
event bus, a declared reaction the runtime fires and records the
outcome of, the same way it records everything else. process_manager
is the seam for the reaction that cannot finish in one step — money
that has to leave one account before it can arrive at another, with a
real chance of failing partway through.
Both are provable against the real corpus, not a small domain invented
to order. examples/banking/bluebook/ already carries
one of each shape this page teaches: a policy that reacts inside its
own domain, one that reaches across into another, one that re-triggers
itself on a bounded retry, and two sagas that coordinate a multi-step
change — one that undoes what it started when the second step refuses,
and one that has nothing to undo because nothing was ever created in
the first place.
ScheduledPayment holds the clearest example of a policy actually
closing a loop — a payment retries its own failed presentment, up to
the limit the schedule names, exactly as it reads in the real file:
```ruby skip
command “Fail” do role “System” goal “Record that today’s presentment could not be collected”
reference_to ScheduledPayment
emits “ScheduledPaymentFailed” end
RetryOnPaymentFailure below re-enters this samegiven is the REAL bound a bank actually enforces — amax_attempts, ordinarily well inside the runtime’s#
command “Retry” do role “System” goal “Re-present a failed payment, up to the limit the schedule names”
reference_to ScheduledPayment
given(“a retry is still allowed”) { attempts.value < max_attempts.value }
sets :attempts, increment: { value: 1 }
ensures(“a retry never lowers the attempt count”) { attempts.value == old.attempts.value + 1 }
emits “ScheduledPaymentFailed” end
policy “RetryOnPaymentFailure” do on “ScheduledPayment.ScheduledPaymentFailed” trigger ScheduledPayment::Retry end
Read the keyword off that block: `policy` is three lines, `on` names
the event, `trigger` names the command it fires, and that is the whole
of it — no condition of its own, because the condition already ran on
the way in (the `given`s on the command it triggers) and the `on` event
is already a committed fact. `Fail` and `Retry` both emit the identical
event name, `ScheduledPaymentFailed`, which is the entire mechanism:
nothing about `policy` cares whether the event it watches for came from
the command that first failed a payment or from a retry re-announcing
the same failure.
## Wiring
```ruby boot
Hecks::Adapters::Folder.new.load_bluebooks(File.join(InMemoryDomain::ROOT, "examples/banking/bluebook"))
Hecks.hecksagon("Banking") do
attaches "Governance"
Banking::Customer.persisted_by("Memory")
Banking::Account.persisted_by("Memory")
Banking::ScheduledPayment.persisted_by("Memory")
Banking::Transfer.persisted_by("Memory")
Banking::OnboardingCase.persisted_by("Memory")
end
Hecks.hecksagon("Governance") do
Governance::RoleAssignment.persisted_by("Memory")
Governance::RoleTransition.persisted_by("Memory")
end
A scheduled payment needs an account to stand against:
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c3" },
name: { given: "Kofi", family: "Mensah" }, email: { address: "kofi@example.com" } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c3", number: { value: "a3" },
kind: { name: "current" }, daily_limit: { cents: 50_000 } })
runtime.dispatch("Banking::ScheduledPayment.Schedule", with: { account: "a3",
instruction: { value: "instr1" }, amount: { cents: 500 },
recipient: { value: "Landlord Realty" }, due_on: { value: "2026-09-01" } })
Banking::ScheduledPayment.find("instr1").attempts.to_h # => { value: 0 }
Banking::ScheduledPayment.find("instr1").max_attempts.to_h # => { value: 3 }
Schedule names no max_attempts of its own — there is no argument
for it — so every schedule in this domain gets RetryLimit’s declared
default, 3. Fail the presentment once, and watch what one dispatch
actually does:
before = runtime.reactions.size
runtime.dispatch_flat("Banking::ScheduledPayment.Fail", instruction: "instr1")
cascade = runtime.reactions[before..]
cascade.size # => 4
One call to Fail produced four reactions, not one — Fail emitted
ScheduledPaymentFailed, RetryOnPaymentFailure matched it and
dispatched Retry, Retry’s own mutation succeeded and emitted
ScheduledPaymentFailed again, and the policy matched its own
trigger’s output a second time, and a third, before the schedule’s
given finally had something to say:
cascade.count { |r| r[:delivered] == true } # => 3
cascade.count { |r| r[:delivered] == false } # => 1
cascade.find { |r| r[:delivered] == false }[:reason] # => "Retry refused — a retry is still allowed"
delivered: true three times because Retry accepted the call while
attempts still read below max_attempts — 0, then 1, then 2. The
fourth attempt found attempts.value == 3, given("a retry is still
allowed") came back false, and react recorded the refusal instead of
letting it fly, the same way it would for any command a policy’s
trigger reaches: GivenNotMet and the rest of the refusal family are
caught and written down; a real defect — a typo in a trigger, an
argument the event never carries — is not, and reaches you the way any
other bug would. What is left standing:
Banking::ScheduledPayment.find("instr1").status # => "failed"
Banking::ScheduledPayment.find("instr1").attempts.to_h # => { value: 3 }
Three real attempts, recorded on the record itself, and a status that
never left "failed" — Retry’s own lifecycle transition is "Retry"
=> "failed", from: "failed", so a successful retry is still a failure
until someone dispatches something else.
Nothing about policy’s declaration proves this loop terminates — a
policy is not required to prove that, any more than a given is
required to prove anything about the commands it unblocks later. What
actually stopped this one was given("a retry is still allowed"), a
fact about a specific schedule’s max_attempts, not the runtime
stepping in. The runtime’s own backstop sits well outside where this
example ever reached it:
Hecks::Runtime::Dispatcher::MAX_REACTION_DEPTH # => 5
Four reactions from one Fail is comfortably inside five, and there is
currently no command on ScheduledPayment that lets a caller raise
max_attempts past it — every schedule in this domain retries at most
three times before Abandon is the only way forward. A policy that
genuinely never stopped — the same shape RetryOnPaymentFailure
carries, minus the given that bounds it — would instead run into
Dispatcher::MAX_REACTION_DEPTH itself: the reaction that would be the
sixth in a chain is refused with reaction depth 5 reached instead of
firing, and everything dispatched before it stands as it was. That is
proved directly, on a fixture built for nothing else, by this repo’s
own dispatcher tests (spec/fixtures/reflex.bluebook) — not
reproduced here, because banking’s own retry never needs it: the
business rule that bounds a real retry is also the one you would
want to hit first, in production, before a ceiling built to catch a bug
ever has to.
By default a policy forwards the event’s WHOLE payload as the trigger’s arguments, verbatim — not reshaped, not filtered — so unless you say otherwise, the event’s shape and the trigger’s argument shape have to agree before either is written.
Banking’s own corpus shows what that costs when they don’t.
Customer.Suspend emits CustomerSuspended carrying its own two
arguments — the customer’s reference and the standing it was
suspended with — and the policy reacting to it triggers
Account.FreezeAccount, which has no use for either. Forwarded whole,
FreezeAccount would have to declare a standing it never reads just
to be reachable, and Account would need a value object declared
solely to type it.
with: is how a trigger says what it actually needs — the same
key => value shape a saga’s own dispatch ..., with: takes, and read
the same way: a Symbol names a field on the source, anything else is a
literal the policy supplies itself.
```ruby skip
policy “FreezeAccountsOnSuspension” do on “CustomerSuspended” for_each “Account.OpenForCustomer” trigger Account::FreezeAccount, with: { account: :account } end
`account` is the key the fan-out merges for each row it answers. The row
is part of the source a projection reads from, not something bolted on
afterwards — which is what lets a trigger be given the record it acts on
and nothing else.
The other half is `for_each`. `CustomerSuspended` names a CUSTOMER, and
a customer may hold several accounts — nothing in the event says which
one to freeze, or how many. Without the fan-out there is no account for
the trigger to address at all, which is why this policy sat in the
corpus for a long time never once firing. With it, one suspension
reaches every open account that customer holds:
```ruby
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c2" },
name: { given: "Nadia", family: "Osei" }, email: { address: "nadia@example.com" } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c2", number: { value: "sus-1" },
kind: { name: "current" }, daily_limit: { cents: 50_000 } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c2", number: { value: "sus-2" },
kind: { name: "savings" }, daily_limit: { cents: 10_000 } })
before = runtime.reactions.size
runtime.dispatch_flat("Banking::Customer.Suspend", reference: "c2", standing: { value: "under review" })
# Filtered by policy: each freeze this one causes goes on to fire
# `ReviewOnFreeze` in its own right, so the log carries those too.
froze = runtime.reactions[before..].select { |row| row[:policy] == "FreezeAccountsOnSuspension" }
froze.map { |row| row[:for_row] } # => ["sus-1", "sus-2"]
froze.map { |row| row[:delivered] }.uniq # => [true]
Both accounts moved, and nothing at the call site named either of them:
Banking::Customer.find("c2").status # => "suspended"
Banking::Account.find("sus-1").status # => "frozen"
Banking::Account.find("sus-2").status # => "frozen"
Get the shapes to agree — a lean port or event carrying only what its
trigger needs — and this class of bug has nowhere left to hide; get
them wrong and the reaction dies as UnknownArgument or
AbsentArgument on every single delivery, recorded in the reaction log
and nowhere a caller will look. Which is why an external fact belongs
on a narrow, purpose-built event rather than one bookkeeping-heavy
enough to drift from what reacts to it.
A policy does not care where it was written. One declared inside
aggregate "ScheduledPayment" and one declared at the bluebook’s top
level — banking’s corpus has both, and you have already seen one of
each — behave identically at runtime; on’s event name is all the
interpreter reads. And when the command a policy triggers lives in a
different domain entirely, across names it:
```ruby skip
policy “ReviewOnFreeze” do on “Account.AccountFrozen” trigger AccountFreezeReview::Open across “Compliance” end
Banking's own `Compliance` domain is real — its own standalone
deployment, `examples/compliance` — but this guide never loads it, and
that is itself worth seeing, because `across` does not pretend
otherwise. Open an account, freeze it, and the policy still fires,
reaching for a domain that genuinely is not loaded HERE:
```ruby
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c1" },
name: { given: "Théo", family: "Lindqvist" }, email: { address: "theo@example.com" } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c1", number: { value: "a1" },
kind: { name: "current" }, daily_limit: { cents: 50_000 } })
before = runtime.reactions.size
runtime.dispatch_flat("Banking::Account.FreezeAccount", number: "a1")
runtime.reactions[before..].size # => 1
runtime.reactions.last[:trigger] # => "Compliance::AccountFreezeReview.Open"
runtime.reactions.last[:delivered] # => false
runtime.reactions.last[:reason] # => "no domain \"Compliance\" loaded (verb Compliance::AccountFreezeReview.Open)"
Banking::Account.find("a1").status # => "frozen"
Freeze itself is untouched by any of this — the account is frozen
either way, because a policy reacts to a committed fact and cannot
un-commit it. What across buys you the moment Compliance IS loaded
in the same process is a real cross-domain dispatch, recorded the same
way; what it buys you here, with Compliance absent, is a named
refusal instead of a silent no-op or a crash — runtime.reactions
tells you exactly which domain a policy reached for and did not find,
which is the difference between a page you can debug from its own log
and one where a reaction just quietly never happened.
process_manager — a conversation with real stateA policy answers one event with one trigger. Settlement answers a
question a policy cannot: a transfer has to leave the source account
before it can arrive at the destination, and something has to remember
which conversation a later event belongs to.
```ruby skip
process_manager “Settlement” do
# THE FIELD, NAMED — Transfer.Request declares attribute :reference,
# TransferReference, which is also what Transfer’s own identified_by
# :reference derives its id from, so this reads the exact same scalar
# the old bare :transfer reached only by coincidence (the aggregate’s
# own reference key happening to spell the same as the correlation
# name). Named, not guessed.
correlates_by :”reference.value”
starts_on “TransferRequested”
ends_on “TransferSettled”
# ONE STATE-MACHINE VOCABULARY (S7, ADR 0025) — the SAME word
# lifecycle’s own transition already carries, one level over;
# state "x" lines are gone, since every state here is already
# named by some transition’s own from:/target — declaring them
# again said nothing the transitions below didn’t already say.
transition “TransferRequested” => “requested”, from: “requested” do
# compensates — PER-DISPATCH SAGA COMPENSATION, one leg at a time,
# replacing a hand-written list at the bottom of this saga (see
# “Compensation,” below, for the real bug this closes).
dispatch Account::Debit, with: { number: :source, amount: :amount, narrative: { text: “transfer out” }, reference: :reference } do
compensates Account::Credit, with: { number: :source, amount: :amount, narrative: { text: “transfer reversed” } }
end
end
transition “AccountDebited” => “awaiting_credit”, from: “requested” do dispatch Transfer::Debited, with: { transfer: :reference } dispatch Account::Credit, with: { number: :destination, amount: :amount, narrative: { text: “transfer in” }, reference: :reference } end
transition “AccountCredited” => “awaiting_credit”, from: “awaiting_credit” do dispatch Transfer::Credited, with: { transfer: :reference } end
transition “TransferCredited” => “settled”, from: “awaiting_credit” do dispatch Transfer::Settle, with: { transfer: :reference } end
# Transfer::Reverse stays hand-written — it marks the TRANSFER
# reversed, unconditionally, not one dispatch’s own effect, so it is
# not a compensates candidate; see “Compensation,” below.
transition :refused => “reversed”, from: “awaiting_credit” do
dispatch Transfer::Reverse, with: { transfer: :reference }
end
end
`correlates_by` says which conversation an event belongs to — always a
dotted path to one scalar, never a bare value object, for the same
reason `identified_by :reference` already holds `Transfer`'s
own identity to: a value object has no single unambiguous rendering to
key on. `starts_on`/`ends_on` bound the conversation; `transition` is
the SAME word `lifecycle`'s own `transition` already carries one level
over — `"EventType" => "to_state", from: "from_state"`, differing only
in what triggers it (a command there, an event here) — and each block
is one leg: an event that must arrive while the instance is in `from:`
before the saga dispatches whatever that leg does next and moves it to
the state named. `state "x"` lines don't exist any more — every state a
saga can be in is derived from the transitions that name it, first-seen
order, the same way an aggregate's own lifecycle states already are.
`from:` is mandatory here, unlike `Lifecycle#transition`'s own optional
`from:` — a saga's admission check tests a running instance's current
state by plain equality, with no "matches any state" fallback, so a
transition naming no `from:` is refused at declaration rather than
silently matching nothing at runtime.
Open two accounts under one customer, fund the source, and ask for a
transfer between them:
```ruby
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c4" },
name: { given: "Rosa", family: "Klein" }, email: { address: "rosa@example.com" } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c4", number: { value: "src1" },
kind: { name: "current" }, daily_limit: { cents: 100_000 } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c4", number: { value: "dst1" },
kind: { name: "current" }, daily_limit: { cents: 100_000 } })
runtime.dispatch_flat("Banking::Account.Credit", number: "src1", amount: { cents: 1000 }, narrative: { text: "opening balance" })
runtime.dispatch("Banking::Transfer.Request", with: { reference: { value: "tr1" }, amount: { cents: 200 },
narrative: { text: "rent" }, source: "src1", destination: "dst1" })
Banking::Account.find("src1").balance.to_h # => { cents: 800, currency: "USD" }
Banking::Account.find("dst1").balance.to_h # => { cents: 200, currency: "USD" }
Banking::Transfer.find("tr1").status # => "settled"
Nobody dispatched Debit, Debited, Credit, Credited, or Settle
directly — Settlement did, one leg at a time, exactly as its handlers
declare, ending on TransferSettled, which is also ends_on — the
instance closes there and stops being tracked:
runtime.sagas.select { |s| s[:instance] == "tr1" && s[:ended] }
# => [{ process_manager: "Settlement", on: "TransferSettled", instance: "tr1", ended: true }]
runtime.registry.saga_instances["Settlement"].key?("tr1") # => false
Freeze the destination before asking for a second transfer, and the
Credit leg refuses partway through — after the money already left the
source:
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c5" },
name: { given: "Iris", family: "Falk" }, email: { address: "iris@example.com" } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c5", number: { value: "src2" },
kind: { name: "current" }, daily_limit: { cents: 100_000 } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c5", number: { value: "dst2" },
kind: { name: "current" }, daily_limit: { cents: 100_000 } })
runtime.dispatch_flat("Banking::Account.Credit", number: "src2", amount: { cents: 1000 }, narrative: { text: "opening balance" })
runtime.dispatch_flat("Banking::Account.FreezeAccount", number: "dst2")
runtime.dispatch("Banking::Transfer.Request", with: { reference: { value: "tr2" }, amount: { cents: 200 },
narrative: { text: "rent" }, source: "src2", destination: "dst2" })
Banking::Account.find("src2").balance.to_h # => { cents: 1000, currency: "USD" }
Banking::Transfer.find("tr2").status # => "reversed"
1000, not 800 — the money is back where it started, because a refused
leg unwinds on its own. Nobody dispatched Reverse by hand, and nobody
had to notice the transfer had stalled: Account.Debit’s own compensates
Account::Credit is what actually restores the balance here — the
runtime tracks which of THIS instance’s own dispatches completed
(Account.Debit did; Account.Credit is the one that refused, so it
has nothing of its own to undo) and, the moment the refusal hits
transition :refused => "reversed", from: "awaiting_credit", dispatches
every completed leg’s own compensates first, newest first, THEN this
leg’s own hand-written Transfer.Reverse — coexistence, not
replacement: marking the transfer itself reversed is not any ONE
dispatch’s own undo, it is a fact this saga needs unconditionally
whenever a refusal lands here. Read the saga log and the refusal, the
derived compensation, and the hand-written mark are all right there,
not swallowed:
refused = runtime.sagas.select { |s| s[:instance] == "tr2" && s[:delivered] == false }
refused.map { |s| s[:reason] } # => ["Credit refused — status is \"frozen\", and Credit moves it only from \"open\""]
derived = runtime.sagas.select { |s| s[:instance] == "tr2" && s[:compensation] }
derived.map { |s| [s[:dispatch], s[:delivered]] } # => [["Account.Credit", true]]
runtime.sagas.select { |s| s[:instance] == "tr2" && s[:dispatch] == "Transfer.Reverse" }.map { |s| s[:delivered] } # => [true]
Only ONE compensation is DERIVED — Account.Debit’s own Account.Credit —
never Account.Credit’s own effect, because that leg is the one that
refused; it never completed, so it never joined the ledger of what to
undo. This is exactly the shape a single hand-written list at the
bottom of the saga could not express correctly in general: it has no
way to know WHICH legs actually ran before a refusal, only what the
author guessed would always be true — Transfer.Reverse alone, staying
hand-written, is what the ORIGINAL list should have been all along; the
money movement is what actually needed per-leg tracking.
Without this compensation, a refusal like this would leave the money
gone from the source, credited nowhere, until a human noticed the
stalled transfer and corrected it by hand — and this saga’s own history
says exactly this happened once: the compensating leg used to hang off
"TransferReversed", an event only a human dispatching
Transfer.Reverse by hand would ever produce, so the reversal was
written and never armed. :refused is a refusal, not an event any
command emits, which is why the leg still answers it by name rather
than by an on string nothing announces — only WHERE its own
dispatches come from changed, not the trigger itself.
“Nobody had to notice” is a claim about a refusal, not about a
crash. Everything above happens inside one dispatch, one process,
with nothing killed in between — Account.Credit declines, and
unwind runs in the same call stack that asked for the credit. A
process that dies partway through is a different case, and there are
two windows. Between the triggering command’s commit and its
reactions: the transactional outbox (Runtime::Outbox, ADR 0053)
closes this one. On Sqlite/Postgres/PostgresEra, the command’s save,
its event, and one pending row per consumer — every policy that
would fire, every process manager that would begin, advance or end on
the event — commit in ONE transaction; the dispatcher then claims and
delivers each row inline. A crash before delivery leaves the row
pending, and the next boot redrives it (its consumer provably never
started). A crash inside a consumer leaves it claimed; the next
boot warns ([hecks] outbox row ... claimed before the last crash ...)
and leaves it for runtime.outbox.redrive!(claimed: true) — never an
automatic retry, because redelivering a dispatch whose outcome is
genuinely unknown risks a double-credited transfer, a strictly worse
defect than the stall. Between a saga’s checkpoint and its own leg’s
dispatch: the checkpoint commits the new state before that leg’s
dispatch runs (the mutex guarding the checkpoint can’t be held across
a dispatch that might re-enter it), so a crash there leaves a state on
record with no proof its leg ran. Booting against the same durable
adapter surfaces it — a loud [hecks] ... rehydrated ... with a
dispatch left pending ... warning and a rehydrated_stalled: true
entry in runtime.sagas — and, for the same reason as a claimed
outbox row, does not auto-redrive it. Both windows are now visible
across a restart; the second, like a claimed row, still needs a
human to look and decide.
Settlement compensates because a transfer stopped halfway is money
unaccounted for — something must be put back. Onboarding, banking’s
newest saga, does not, and its own comment says exactly why:
```ruby skip
on :refused leg because a transfer stopped halfway ison :refused leg here — compare its shape against Settlement’s aboveprocess_manager “Onboarding” do correlates_by :”reference.value” starts_on “OnboardingOpened” ends_on “AccountOpened”
transition “OnboardingCleared” => “cleared”, from: “screening” do dispatch Account::Open, with: { customer: :customer, number: :account_number, kind: { name: “current” }, daily_limit: { cents: 0 } } end
# THE NON-COMPENSATING LEG — no dispatch, because nothing was ever # created to undo. transition “OnboardingDeclined” => “declined”, from: “screening” end
Clear a case, and the saga does what `Settlement` does — dispatches the
one leg it owns and closes on `ends_on`:
```ruby
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c6" },
name: { given: "Salim", family: "Haddad" }, email: { address: "salim@example.com" } })
runtime.dispatch("Banking::OnboardingCase.Open", with: { customer: "c6",
reference: { value: "ob1" }, account_number: { value: "acct1" } })
runtime.dispatch_flat("Banking::OnboardingCase.Clear", reference: "ob1")
runtime.sagas.select { |s| s[:instance] == "ob1" && s[:ended] }
# => [{ process_manager: "Onboarding", on: "AccountOpened", instance: "ob1", ended: true }]
runtime.registry.saga_instances["Onboarding"].key?("ob1") # => false
Banking::Account.find("acct1").status # => "open"
Decline one instead, and the saga’s own log tells the rest of the story on its own:
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c7" },
name: { given: "Priya", family: "Nair" }, email: { address: "priya@example.com" } })
runtime.dispatch("Banking::OnboardingCase.Open", with: { customer: "c7",
reference: { value: "ob2" }, account_number: { value: "acct2" } })
runtime.dispatch_flat("Banking::OnboardingCase.Decline", reference: "ob2")
runtime.sagas.select { |s| s[:instance] == "ob2" }.size # => 2
runtime.sagas.select { |s| s[:instance] == "ob2" && s.key?(:dispatch) } # => []
Banking::OnboardingCase.find("ob2").status # => "declined"
Banking::Account.find("acct2") # => nil
Two log entries — the saga being born, and the one transition to
"declined" — and neither one is a dispatch, because transition
"OnboardingDeclined" => "declined" names no block at all. No account was minted for
"acct2" because nothing ever asked for one. And notice what did NOT
happen: runtime.registry.saga_instances["Onboarding"] still holds
"ob2" —
runtime.registry.saga_instances["Onboarding"].key?("ob2") # => true
— because ends_on names "AccountOpened", and a declined case never
emits it. "declined" is a real, terminal state as far as the KYC case
itself is concerned, but the SAGA’s own bookkeeping only closes on the
event it was told to end on — a distinction worth knowing before you
assume every saga that reaches a natural stopping point also stops
being tracked.
Everything above unwinds because the DOMAIN refused a leg — given said no,
a lifecycle move wasn’t admitted, Credit declined. Two other ways a leg
can fail to run are not domain decisions at all, and this saga’s own
runtime treats them differently from each other.
Hitting Dispatcher::MAX_REACTION_DEPTH (the reaction depth limit,
above) mid-saga isn’t a decision either, but there’s nothing ambiguous
about it — the leg unambiguously did not run — so it unwinds on the spot,
the same on :refused leg a real refusal triggers, no retry involved:
```ruby skip cascade = runtime.sagas.select { |s| s[:instance] == “tr9” } cascade.find { |s| s[:reason] == “reaction depth 5 reached” }
A genuine crash — a `NoMethodError`, a `NameError` from a typo in a
`dispatch`'s argument mapping — is the one case that's actually ambiguous:
it might be a real bug (retrying changes nothing) or it might be
transient (a DB timeout, a race, a cold start — retrying clears it). So it
gets `SagaInterpreter::MAX_DEFECT_RETRIES` attempts at the identical
dispatch before the saga treats it as something to compensate for. Every
attempt is logged (`defect: true`, `retrying: true`); only the last one,
once retries are exhausted, unwinds — and it's tagged
`defect_compensated: true` rather than folded into an ordinary refusal's
shape, so the log never claims the domain decided something it didn't:
```ruby skip
cascade.select { |s| s[:defect] }.map { |s| s.values_at(:attempt, :retrying, :defect_compensated) }
# => [[1, true, nil], [2, true, nil], [3, true, nil], [4, nil, true]]
Both paths still leave the ORIGINAL triggering command’s own success standing — same as an ordinary defect always has — the only thing new is that the saga no longer just logs a defect and waits for a human to find it; it puts back what it can and moves on.
hecks model_check still catches hereEvery policy and every process manager here is data, the same data the
runtime dispatches against, which means the checker verification.md
covers in depth can walk it with nothing booted. Four of its finding
kinds are specific to this page’s own vocabulary: deaf_trigger (a
saga’s starts_on/ends_on names an event nothing emits),
unreachable_pm_state (a declared saga state no handler chain ever
reaches), dead_compensation (a compensation’s own from_state no
handler chain ever reaches), and deaf_policy / unknown_trigger (a
policy’s on or trigger naming something that isn’t there). Run it
against the same domain this page just walked:
require "hecks/bluebook/model_check"
findings = Hecks::Bluebook::ModelCheck.call(runtime.registry.bluebook("Banking"))
errors = findings.select { |f| f.severity == :error }
errors.size # => 0
Zero, and ModelCheck::ALLOWED_FINDINGS is empty too — not because
nobody looked, but because a finding this checker used to allowlist on
purpose stopped being possible. ExternalSettlement used to declare a
state "sent" line no handler’s own from:/target ever named — a pure
declaration-drift artifact, the SAGA’s own bookkeeping never closing on
a state its own protocol never reached, even though the underlying
transfer genuinely sends. Since transition (the one word this page
now uses throughout) DERIVES a saga’s states from the transitions that
name them, the same way an aggregate’s own lifecycle states already
are, there is no longer a way to declare a state and then forget to
wire a handler that reaches it — the finding this used to catch cannot
occur any more, by construction, so verification.md’s own allowlist
has nothing left to name.