Status: Accepted Date: 2026-08-02 Deciders: Runtime team, Platform architecture
ADR-0058 refused RaceUntil as a fork
over two suspending branches and named the honest shape in its place:
.PollUntil<T>(…).OrSignal<TSignal>()— one wait with two ways to end it, oneTakeSignalcall.
It called that a work package rather than a design question, and most of it is. A signal
identity on the Poll node, a TakeSignal call on the arrival path, the signal's contract in
the state bag, a route from EndpointEmitter, a signal field on a Poll step in the manifest
— none of those is contested, and each falls out of a construct that already exists beside it.
One thing does not, and it contradicts ADR-0058's own decision 5.
That decision says a poll node commits no row, and that what stops a resumed instance polling
one more time is the predicate: "the last attempt's answer is in the restored state bag and
until still holds". That argument is exactly right for a poll with one ending, and it fails
for a poll with two. When a delivery is what ended the wait, until says not yet — that is
why the delivery mattered — and the signal has been consumed by the invocation that took it,
because DurableExecution.PendingSignal is one slot per invocation. So an instance resumed past
a signal-ended poll finds a poll that is not finished by any test it can perform, and goes back
to calling the provider. For ever, once per touch.
A poll ended by a delivered signal commits one row, on the poll's own node, under the poll's
own scope. That row is the whole of the difference between a poll with one ending and a poll
with two; every other part of .OrSignal<TSignal>() is the existing wait machinery reused.
Concretely:
-
The row is the witness the predicate cannot be. It is committed by
FlowEngine.CommitStepAsync— the same call every other step boundary uses — under(instance, poll scope, poll index, attempt 1), carrying the state-bag snapshot that holds the delivered payload. On a later arrival the step loop's ordinary frontier scan finds it and skips the node, exactly as it skips a satisfiedAwaitSignal. Nothing new reads it and nothing new writes it. ADR-0058's decision 5 is unchanged for a poll that declares no signal: that poll still commits nothing, and the predicate is still what says the loop is over. -
StepNode.Identitynames the signal, so the row can be read. A poll with one ending has an empty identity and never commits, so the two facts arrive together: the onlycapability_ida poll node can ever put in an instance's history is the identity of the delivery that ended it. An operator readingflow_stepseesocr.completedat the poll's index and knows which of the two endings happened, without joining anything. -
The three questions are asked in a fixed order: predicate, signal, schedule. Each position is load-bearing.
The predicate first, because a finished poll is re-walked on the way to whatever the instance is actually parked at. A poll that took a delivery there would consume the signal a later wait in the same flow is open for — one slot, one invocation — and leave that wait parked until its timeout on a delivery that was made.
The signal before the schedule, because that is the sentence the construct exists to make true. A signal arriving between two attempts ends the wait: no attempt is made, the gap the instance was parked on is not waited out, the escalation is not entered, and control continues where a satisfied predicate would have taken it — one past the escalation block, or past the one-step attempt when there is none.
Both after the flow's deadline check, which is where it already was: an instance whose whole budget has gone fails rather than committing another row.
-
One row, one
wake_at, one wake path — unchanged. The instance parks on the sameflow_instancerow carrying the samewake_at/wake_step_id/wake_scopetriple it always did. A delivery arrives at that row; the commit that records the ending clears the wake with the state, because the wait ended once. Nothing here needs a second instant, and if it had, this would have been the fork ADR-0058 refused wearing a different name. -
FLOWX1050reports the one thing two endings make possible. Both endings continue at the same index, so every step after the poll runs on either — and only the delivery ending leavesTSignalin the bag. A step binding it works when the webhook fires and throws when the polling does its job, which is the ordinary path. It isFLOWX1020's argument narrowed to the one construct that produces conditionally, and is reported instead ofFLOWX1020on that line: the type genuinely is in the bag on one of the two paths, so the older rule's advice would be wrong. -
The manifest publishes the signal in the field a wait already uses, and
flowx diffgains no rule. A poll's second ending is an inbound address in exactly the sense a suspension point's is — the identity a transport addresses a delivery to — so it is the samesignalfield, indexed by the sameAwaitedwalk, classified by the sameFLOWX-DIFF-021,022and206. A poll that starts accepting a webhook has a new inbound address; a poll that stops breaks every sender in silence. Both are what those codes already say.
- The webhook fast path exists, which was ADR-0058's one named negative consequence. A provider that publishes completions is no longer polled anyway, and a deployment that wants both no longer runs two flows and correlates them.
- A signal between two attempts costs one row and no extra call.
PollSignalHostTests.ASignalBetweenTwoAttemptsEndsTheSameWaitOnTheSameRowmeasures it against PostgreSQL: one attempt row before, one attempt row after, one poll-node row naming the signal, and the wake gone. - Nothing about the layout moved.
.OrSignal<T>()spends no index: the attempt is stillIndex + 1, the escalation still follows it, and the satisfied path is still where it was. A poll that declares no signal emits the identical node it emitted before. FLOWX1017and theDurable-only requirement are untouched, and could not be otherwise: the second ending is a journal row and aTakeSignalcall, both of which need the journal the first ending already needed.
- ADR-0058 decision 5's argument no longer covers every poll, which is why this record exists rather than a comment. The rule is now: a poll that ends on its predicate commits nothing and is stopped by the predicate; a poll that ends on a delivery commits a row and is stopped by the row. Two rules where there was one, and a reader of decision 5 alone would draw the wrong conclusion about a poll with a signal.
- A poll node can now appear in
flow_step, so a consumer that assumed the poll's index never held a row is wrong. Nothing in this repository assumed it; the assumption was available to be made. - The stale-attempt reading is not checked and cannot be. On the delivery ending the last
attempt's output is by definition the one the predicate said was not terminal, and a step
after the poll binding it will read that.
FLOWX1050reports the mirror image — a step binding the signal — because that one throws; this one merely means something the compiler cannot weigh. It is stated on the diagnostic's page rather than left for a reader to discover. - Two waits on one identity in one flow are still ordered only by the journal. A poll's
second ending and a later
AwaitSignalon the same contract are resolved by "the first with no committed row", which is the ruleAwaitSignalalready documented. Decision 3 is what keeps it true; nothing refuses the declaration.
- A delivery has to reach a poll that has not yet parked — today a signal arriving at an invocation that walks into a fresh poll ends the wait before its first attempt, which is defensible and untested against a real deployment. A deployment that observes it and wants the first attempt made anyway would be arguing for the signal to be asked after the attempt.
- A poll needs more than one second ending.
IPollBuilder.OrSignalreturnsIAwaitBuilder, so a second call does not compile — deliberately, because one row has one delivery slot. Two would need the invocation to carry a set rather than a signal, which isDurableExecution.PendingSignal's own revisit condition and not this one's. flow_stepgains a column that says why a step committed, at which point decision 2's use ofcapability_idto distinguish a poll's two endings becomes a workaround rather than the only place to put it.