0029. Stop a delivery run on a fault of the rule, park a record on a fault of the record¶
Date: 2026-09-10
Status: Accepted
Context¶
The delivery runner (CFX-216) is the first piece of code that writes a DeliveryStatus at all. The
four stages under it were built one at a time and each of them settled a question in isolation; the
runner is where all four meet a live target and start producing outcomes.
Two of those settlements decide what the runner has to cope with. Under
ADR-0015 the ledger row decides
create against update before the writer is asked: a row holding a remote_id means update by that
reference, anything else means create, and nothing interrogates the target to check. Since
ADR-0021 a writer states which verbs it offers in
its type, and a target that has no create or no update is an ordinary target rather than a broken
one. Put together, the decision the ledger makes can land on a verb the writer in front of it does
not have.
A record can go wrong on its own account too. The target can refuse the write for reasons that pass — a timeout, a rejected field, a rate limit — or answer that the record it once created is not there any more, or take a create and hand back nothing that addresses the result, which leaves a record in the target that nothing can ever reach again. And a chain as a whole can be stopped by something that has nothing to do with any record: either side of the route stops being healthy, or the circuit breaker from ADR-0009 trips because the target is down. A rule has one job at a time, and a job that finds the rule taken is not a fault of anything: it waits and runs once the rule is free.
So every fault a chain meets has to be routed to one of two places: the chain, or the row of the record it struck. Both directions of the mistake cost something, and neither cost is theoretical. Routing a record's fault to the chain stalls the rule behind one bad record, which is the failure the cursor design already refuses by advancing past every record it attempted. Routing a rule's fault to the records writes one ledger row per record for a single fact, gives each of those rows a retry schedule that can never succeed, and lets the changes themselves pass the cursor with nothing recording that they were never delivered.
Decision¶
A fault that would fall the same way for every record of the rule ends the chain, and nothing is written for it. There are six, and none of them is about the record the step happened to be holding:
| Fault | How the chain ends |
|---|---|
| The route is no longer open — a rule switched off, a connection unhealthy | DeliveryResult::RouteClosed |
| The circuit breaker for the target is open | DeliveryResult::BreakerOpen |
| No writer is registered for the target's driver | RuntimeException from the registry |
| The writer lacks the verb the ledger decision needs | UnsupportedDeliveryVerbException |
| The insert of a ledger row violates the unique key | UniqueConstraintViolationException |
A mirror record carries no position or no source_id |
LogicException from DeliveryRunner |
A fault that belongs to one record lands in that record's ledger row, and the cursor moves past
it. A transport failure counts an attempt and takes a failed row with the time of its next one.
A target reporting the record gone takes remote_gone. A create that comes back unaddressable takes
exhausted, because the record now exists in the target and a second attempt would make a second
copy of it. The job carries on with the next candidate in the batch.
exhausted is a status of its own, rather than failed with a null next_attempt_at. It
covers both ways a record runs out of road: the retries are used up, or the create produced
something unaddressable. DeliveryStatus::isTerminal() returns true for it and for remote_gone,
and those two are the whole of the terminal set — a delivered or suppressed row is offered again
as soon as the record's change signal moves.
Two of the six are ordinary return values; the other four fail the job and cancel the batch. A
closed route and an open breaker are DeliveryResult cases, returned rather than thrown, because the
step did what the situation called for and a later chain resumes where this one stopped: the
connection comes back, the breaker cools down. The job adds no successor, the batch closes as
finished, and the next tick starts a chain again once the route is open. The other four are
exceptions that reach the queue and land in failed_jobs, and a failed job cancels its batch, which
ends the chain, because nothing about a later attempt would fix them. A driver without a writer, a
writer without a verb the rule needs, a ledger row that exists where the scan said there was none, a
mirror row the scan should never have handed back at all — each is a configuration or an invariant,
and each wants somebody to look at it rather than a retry. The last of them is raised rather than
skipped for the reason the whole ADR turns on: a candidate passed over silently is a change that
leaves no trace of never having been delivered.
Whichever way a chain ends, nothing is rolled back. What was delivered stays delivered, and the cursor stands where the last batch that concluded left it, so the rule resumes rather than restarts.
Retries come before fresh deliveries. A chain opens with the drain and ends with the scan. The drain's set is finite and shrinks within a chain, because every row it attempts either lands or is given a next attempt in the future. The scan's set is the rule's changes, and a rule whose changes arrive faster than it delivers keeps its scan moving indefinitely; a chain that scanned first would never reach its drain for such a rule, and a record that failed once would never be retried. Putting the drain first bounds that: fresh changes wait for the due retries of that tick, which are at most what has come due since the last chain, and never the other way round.
The alternative that was not taken: a ledger status per record for a missing verb¶
A missing verb could be recorded like any other per-record outcome, with a status of its own on every candidate that needed it. It was rejected because the fault belongs to the rule. Every record under that rule needing that verb fails in exactly the same way, so one row per record is a thousand copies of one fact, and each of those rows would then be picked up by a retry pass that cannot succeed until somebody changes the configuration. Halting is loud, loses nothing, and once CFX-217 validates a rule's policy against the target's verbs at save time, the case stops arising at all.
The alternative that was not taken: skipping with a log line¶
The cheapest reaction to a candidate the writer cannot take is to pass over it and log. It was rejected because the cursor moves past a skipped candidate, and once it has cleared the overlap window a delta scan has nothing that brings the record back. The changes would be gone with only a log line saying so, which is exactly the shape of failure that is discovered months later by somebody comparing two systems by hand.
The alternative that was not taken: failed plus a null time in place of exhausted¶
Reusing failed and reading next_attempt_at is null as "given up" adds no enum case, and the
data already distinguishes the two states. It was rejected because it hides the meaning in a
combination of two columns. Every rule that has to leave such a record alone would have to read both,
an operator filtering the ledger would have to know the convention, and a row written with a null
time by mistake would silently become a record nobody delivers again. An explicit status keeps the
skip rules status-based and gives the admin panel something to filter on.
Consequences¶
CFX-217 moves the verb check to save time. Validating a rule's delivery policy against what the
target's writer can actually do is that ticket's job, and it makes UnsupportedDeliveryVerbException
unreachable through the ordinary path. The exception stays as the assertion behind the validation,
in the place where the write would otherwise happen.
A misconfigured route fails its job on every tick until somebody fixes it. A system that declares
push_out for a domain and has no writer registered for its driver produces a failed job and a
cancelled batch every five minutes, and the same holds for a rule whose target lacks the verb the
rule needs. That is deliberate. The alternative is a route that quietly delivers nothing, which looks
identical to a route with nothing to deliver.
An operator finds parked records by status. exhausted and remote_gone are queryable, each row
carries the last_error that put it there, and next_attempt_at is null on both, so nothing picks
them up again on its own. Getting a record out of either state means changing the row, and no
interface offers that yet: until one does, an operator resets it in the database.
The connection is not set to Error on an open breaker. This differs deliberately from the
inbound BulkSyncJob, which does mark its source connection. For a target the breaker's own cooldown
is the intended recovery, and the next tick tries again by itself. Setting the connection to Error
would close the route on the health gate every route lookup passes through, and keep it closed until
a person brought the connection back by hand — a manual step for an outage that resolves itself.
A job that finds the rule taken waits; a job that throws takes the chain with it. Exclusivity is
a job middleware holding a lock for the length of one job, rather than a lock held for the length of
a whole pass. A job that cannot take the rule's lock is released and offered again seconds later, and
that release counts as no attempt. The only collision it has to cover is a tick that judged the
previous chain dead, and that tick has already cancelled the dead chain's batch, so a job of the
cancelled chain that does turn up ends the moment it starts: nothing reaches the ledger and nothing
moves the cursor. No job is ever retried after an exception, either, and a timeout is held to the
same rule rather than a looser one: $maxExceptions counts a timeout against the same budget it
counts a thrown exception against, so the first timeout fails the job exactly as the first exception
does. The first exception to leave a job fails it for good and cancels its batch, which ends the
chain, because every exception in the table above is a fault of the rule that a second attempt would
meet again.
A record parked in a terminal status stays parked however often its source row changes. That is
the point of the two terminal values, and it is the one place where delivery deliberately ignores a
change signal. For remote_gone it protects a deletion somebody made in the target on purpose; for
exhausted it stops a record the target has refused five times from being offered a sixth, and stops
an unaddressable create from being repeated into a second unreachable copy.
A fresh change waits behind the retries that are due when its chain starts. For a rule whose target is healthy that is a few rows; for a rule whose target rejects everything it is the whole due set, once per tick, and the breaker halts the chain before the scan if the target is down.