RFDs/0004

Durable run state machine and work queue

State
discussion
Authors
Ryusen contributors
Discussion
chaba-dev/ryusen#1
Labels
enginestate-machinequeueleasescompensationbenchmarksperformance
Implemented
74%Checklist

Goal

Define the transactional state machine that turns protocol declarations into durable forward and compensation work. Internal state and resulting work must commit together so retries repair ambiguous acknowledgements without cross-store cleanup.

Durable records

The initial engine persists versioned records for application-manifest generations, workspace routing generations, connected release registrations, events, fan-out progress and matches, runs, steps, attempts, immutable history entries, work items, leases, and idempotency claims. Secondary indexes are derived durable data maintained in the same transaction as their source records.

A run moves through Running, DefinitionBlocked, Cancelling, Compensating, and Terminal phases. Fan-out creates it as Running together with its first discovery work. Running includes work queued for dispatch or waiting for a compatible worker; those are operational statuses rather than lifecycle phases. The initial model has no generic Pending phase. Future scheduling, admission, or approval features should introduce states with their precise semantics. DefinitionBlocked is a recoverable pause caused by a workflow-definition conflict, not an execution failure. Cancelling also settles work after run timeout; the durable stop reason distinguishes cancellation from timeout. Terminal records preserve two orthogonal outcomes: execution (Completed, Failed, Cancelled, or TimedOut) and recovery (NotRequired, Compensated, PartiallyCompensated, or CompensationFailed). Steps and attempts use a more detailed state machine defined by RFD 1, including Obsolete and Unknown. The recovery value is assigned only when a run terminalizes; a Compensating run blocked by Unknown has no terminal recovery outcome yet.

RunningDefinitionBlockedCancellingsnapshot unwindCompensatingTerminal duplicate definitionfixedstop with unsettled effectsstopall settleduncaught failuresettled stopnonemptyemptysuccess

Every accepted transition appends a history entry with the same commit. History is sufficient to explain why work was created, retried, skipped, made obsolete, or compensated; it is not a second asynchronously updated observability store.

Transactional transitions

At minimum, the following are single transactions:

  • publish a completed immutable application-manifest generation, derive an immutable workspace routing generation over its expected parent, atomically activate both heads, and create archive obligations when configured;

  • accept the first registration for a connected release, assign its durable server order, or reject metadata conflicting with its existing identity;

  • when supplied, claim a workspace event idempotency key and record its request fingerprint, then record the event identity and active workspace routing generation used for routing and create durable fan-out work;

  • advance one bounded fan-out chunk, record evaluated trigger matches, create deterministic Running runs and their discovery work, atomically add a RunTimeout item when an effective deadline exists, and create the next chunk when needed;

  • accept a step declaration and create its targeted forward work;

  • accept a successful attempt result, store it, and create rediscovery work;

  • accept an exhausted attempt, store its terminal step failure, and create rediscovery work;

  • accept a definition_blocked discovery response, persist or update its blocker and diagnostics, move the run to DefinitionBlocked, and prevent new forward callback work;

  • accept a non-blocked rediscovery response, clear its blocker, and atomically apply that response’s ordinary running, terminal, or recovery transition;

  • accept an uncaught workflow failure from discovery, snapshot its unwind, and either terminalize an empty plan or create the first compensation item;

  • accept compensation success or absence and create the next unwind item;

  • request cancellation or timeout, prevent new forward work, fence unstarted and repeatable discovery work, and snapshot authorized effectful attempts that must settle; when none remain, atomically snapshot the unwind and create its first compensation item or terminalize an empty plan;

  • accept a late result or operator resolution for a settling attempt and, when no unresolved authorized effectful attempt remains, create the first compensation item or atomically terminalize an empty unwind;

  • lease, requeue, or complete a durable work item.

An HTTP response is sent only after its authoritative transaction commits. Wake-up publication occurs after commit and is best effort.

Event fan-out

An optional caller idempotency key is scoped by workspace and the event.submit operation. It cannot collide with another workspace’s key or an idempotency key for a different API operation. The submission transaction stores a versioned cryptographic fingerprint of the canonical caller-controlled fields that affect the event record or routing, including event name, payload, caller-supplied timestamp, and routing metadata. It excludes credentials, transport headers, generated event identity, and the server-selected workspace routing generation.

The claim, event, selected workspace routing generation, and first fan-out obligation commit together. An identical retry returns the original event identity and its current durable expansion state without creating another event or fan-out item. Reuse of the scoped key with a different fingerprint returns an idempotency conflict and mutates nothing. Concurrent identical submissions serialize on the claim and converge on the same event. Without a caller key, each accepted submission is intentionally a new event.

workspace + event.submit + key Kevent + fingerprint + fan-out, atomicallyexisting claimoriginal eventconflict, no mutation no claimdifferent fingerprintsame fingerprint

Event acknowledgement does not depend on a live worker or an external event stream. Its transaction reads the workspace’s active routing-generation head and commits the event plus one durable obligation to evaluate that immutable generation. The response returns the event ID, selected routing generation, and expansion state. A later application activation never changes an acknowledged event’s routing set, and worker disconnect never changes trigger registration.

Fan-out work traverses the selected generation’s application-manifest references and evaluates a bounded range of trigger bindings. Its transaction records each binding’s match result, creates a run with a deterministic identity derived from workspace, event, application, and binding IDs as Running with its first discovery item when matched, advances the durable cursor, and queues the next chunk. Retrying any chunk is idempotent. Runs may become visible incrementally, but expansion reaches Complete only after every candidate binding for the event name across every referenced application manifest has a durable evaluated record. Zero matches is a successful complete outcome rather than a lost event.

The initial exact-name matcher can usually finish in one small chunk. The same state machine permits later CEL predicates and hosted fan-out without requiring one unbounded transaction. Every created run records its source event, trigger binding, workspace and application IDs, application-manifest generation, and workspace routing generation so the association remains explainable after new manifests are published.

Event requestEvent + workspace routing W12+ durable fan-out obligationApp A / A7trigger: matchApp B / B4trigger: no matchApp A run +discovery work ack transactionbounded chunksbounded chunks

Work queue

A work item contains workspace, optional application, kind, logical target, priority, vesting time, an immutable application-attempt ordinal, a mutable delivery count, generation, and stable operation ID. Application SDK work always carries exact workspace and application scope. Workspace-wide server work such as event fan-out carries its workspace and corresponding durable owner without pretending to target one application. Lease recovery and redelivery change only delivery identity and fencing; they do not consume or renumber application attempts. A request ID may therefore change across deliveries of one physical item, while protocol attempt and operation_id preserve its physical and logical identities respectively. Ready indexes are ordered by vesting time and priority. Dispatchers scan vested work, acquire a random-token lease with an expiry, and renew long attempts.

The durable queue is shared infrastructure for user execution and internal control-plane operations. Its work kind selects one of three dispatch domains:

  • server-owned work such as event fan-out, run timeout, and durable projection maintenance invokes a transactional engine handler;

  • repeatable discovery is delivered to an SDK worker and may be safely redispatched despite nondeterministic declarations;

  • effectful forward and compensation callbacks are delivered to an SDK worker under start authorization and ambiguity fencing.

Server-owned handlers must be idempotent transactional state transitions. Their lease loss is safely requeued and never becomes Unknown; no connected or HTTP worker advertises capacity for them. Work kinds share persistence, vesting, leasing, wake-up, and retry machinery while retaining independent dispatch and later capacity or fairness policies. This avoids a second scheduler without pretending internal and user work have the same failure boundary.

Lease expiry before start authorization makes an item eligible again. The same is true after authorization for server-owned idempotent work and protocol work classified as repeatable, including workflow discovery. An authorized effectful forward or compensation callback instead becomes Unknown; it is not blindly dispatched again. Result acceptance requires the current lease or an unresolved settlement identity, plus the run and step generation. A stale worker cannot commit state after its work has been retried, cancelled, resolved by an operator, or made obsolete.

lease expiressafe to requeueafter authorizationsafe to replayUnknown before authorizationrepeatableeffectful

The initial SQLite dispatcher may use one global ready index and one process. The record model must permit later tenant partitions and bounded fair dequeue, but hosted fairness is not implemented in this RFD.

Connected dispatch evaluates release eligibility when assigning each work item; the run does not retain a release requirement. It hard-filters the target workspace and application authorization scope, function, current execution contract, protocol and value-codec support, and features required by durable history. Among eligible live groups it prefers the greatest server-assigned release position, uses instance capacity for balancing, and excludes draining connections. The accepted start records scoped release and instance metadata for explanation, not future affinity. HTTP dispatch bypasses release selection but requires the configured endpoint’s credential-derived scope to match before invocation.

Run deadlines

A run has no deadline unless its function declaration or a visible deployment policy supplies one. There is no universal engine ceiling, and local development does not impose one. A hosted policy may define a maximum accepted duration; an exceeding manifest or invocation is rejected instead of clamped. Attempt timeouts and leases remain independent from the whole-run deadline.

When a deadline exists, the fan-out transaction stores its absolute value and source on the run and creates one generation-fenced, server-owned RunTimeout work item vested at that instant. The deadline is authoritative; the item is the durable wake-up mechanism. Every run in Running or DefinitionBlocked retains that item until terminalization or timeout intent consumes it. Queue notification remains best effort because delayed-index scanning rediscovers the item after restart.

fan-out transactionserver-owned handlertimeout settlementRunning run + absolute deadlinefirst discovery workdelayed RunTimeout work vested

The timeout handler and every authoritative forward result or discovery response compare the engine’s current time with the stored deadline in their transaction. A transition evaluated before the deadline may commit normally and atomically remove stale timeout work when it terminalizes the run. At or after the deadline, it first materializes TimedOut stop intent; delayed timer dispatch cannot let completion bypass the deadline. A worker-supplied clock never controls this decision, and conformance tests use an injected engine clock.

Materializing stop intent and recording the reported outcome are one transaction, so a result that arrives at or after the deadline settles the attempt under stopping semantics whether the timer or the result commits first. The run’s execution outcome stays TimedOut in either order, and a retryable reported failure settles without scheduling the retry its declaration would otherwise allow.

An operator resolution of an unresolved forward attempt follows the same rule. A supplied success or a declared failure on a still-running attempt at or after the deadline commits TimedOut stop intent and settles the attempt under stopping semantics in the same transaction: the supplied success completes the step and joins the unwind, the declared failure settles without any forward retry, and the run compensates or terminalizes an empty unwind only once every authorized attempt has settled. Explicit retry is refused as deadline_elapsed with nothing mutated, whether the deadline has elapsed on a still-running attempt or the run has already committed timeout intent. The refusal is decided from durable state, not from the sampled clock alone, so it never depends on whether the timer has been dispatched: waiting cannot make the retry available, and a supplied success or a declared failure can still settle the attempt. The operator’s recorded resolution owns replay of that combined transaction, so a repeated request returns the response the first one committed rather than re-deriving it from the run’s later state.

The first committed stop reason decides. Cancellation and timeout still race normally, and a later clock sample never replaces the outcome that committed: a run stopped as Cancelled keeps refusing explicit retry as retry_forbidden after the deadline elapses, and a run stopped as TimedOut keeps answering deadline_elapsed even if the deadline has since moved below a sampled clock.

If discovery has not been authorized, timeout fences it and terminalizes an empty unwind. Authorized repeatable discovery is discarded. An authorized effectful callback enters normal Cancelling settlement: a late success is accepted, included in the unwind, and can produce TimedOut + Compensated; an ambiguous outcome becomes Unknown. Once timeout intent exists, the original run timer is consumed and compensation uses its own attempt timeout policies.

Step timers

A declaration may be a durable timer rather than a callback. The accepting transaction records the instant the wait may settle at — the acceptance instant plus a relative delay, or the absolute deadline the workflow named, never earlier than acceptance — with the step, and creates one workspace-scoped Timer item vested at that instant on the shared delayed queue. The anchor is durable, so a rediscovery, a replay, or a restart never measures the delay again, and a repeated declaration of the same step keeps the recorded anchor and its wake work.

Firing the item records the accepted wake as an ordinary successful step result and schedules the continuation discovery in the same transaction that completes the work, so a redelivered timer can only find work the first transition consumed. A timer that is not due is deferred rather than consumed. Stop intent fences a pending timer in the transaction that commits the stop, so a cancelled or timed-out run never wakes it, and a settled timer is never part of an unwind: the engine refuses a compensable timer declaration, so a timer can never have a compensator.

The run’s own deadline is authoritative over dispatch order. A due timer that is settled only after that deadline already elapsed does not record a wake: the transition commits the deadline’s stop intent instead, in the transaction that consumes the timer, so no wake and no continuation exist for it whatever order the dispatcher happened to settle its work in.

Step event waits

A declaration may instead be a durable external event wait: the same stable step identity with the exact event name, the correlation the event must carry, and a required relative timeout. The accepting transaction records the registration — the event name, the correlation, the timeout, and the instant the wait may time out at, the acceptance instant plus the timeout — with the step, writes the registration into a wait index keyed by event name and registration ordinal, and creates one workspace-scoped Timer item vested at that instant, exactly as a durable timer does. The anchor is durable, so a rediscovery, a replay, or a restart never measures the timeout again, and a repeated declaration of the same step keeps the recorded registration and anchor.

The registration commit is the registration boundary. An event submission records the current per-(workspace, event name) registration ordinal as its exclusive eligibility bound in the same transaction that accepts the event, so a wait registered later holds an ordinal at or beyond that bound and can never be settled by an earlier event. Matching runs in bounded pages of that bound inside the event’s own fan-out obligation, so an event acknowledgement still creates one fan-out obligation and one bounded expansion; an accepted event settles every eligible registration it satisfies, and an event is never consumed by a wait, so ordinary trigger fan-out is unaffected. Both pages of the expansion transaction are bounded by their count and by the bytes their records will add, because a match copies the matched payload into the settling step record and a materialization copies the scope and trigger identifiers into every run record it writes: a count-bounded page could exceed the storage transaction budget and never commit. Each settlement and each materialization is charged its record, the fixed fields of the mutations that surround it, and the identifiers its keys and values repeat, with every occurrence charged for the escaping a tuple key, a nested journal key, or a JSON value applies to it, with the bytes a backend that namespaces its physical keys prepends to each of them, and with the conflict range every candidate inspection reads — reserved before the page reads the candidate, so a page of registrations the event does not match is bounded too. The binding page spends at most half the transaction budget, the wait page spends what the binding page and the expansion’s own finalization leave, and the finalization reserve covers the obligation or the completed event record with its payload, so the three fit one transaction by construction on a backend whose accounting is its logical mutations and the reads the pages perform. The wait page’s first eligible settlement may borrow the binding page’s allowance when its own share cannot carry it, and the binding page then stops before the borrowed bytes — deferring a binding that no longer fits to the next dispatch with the cursor it had — so a wait whose scope fills the transaction still settles in time instead of starving until its timeout, and the deferral never rolls the settlement back. Either page therefore resumes at the next dispatch instead of failing to commit, and at least one registration is always examined so its cursor keeps advancing.

A registration may be settled by a match only when it is eligible by durable facts: the run must still be running and before its own deadline, the event’s durable acceptance instant must be strictly before the wait’s anchor, and the matching transition’s own sampled instant must also be strictly before it. The timeout is therefore authoritative over dispatch order, exactly as the run deadline is for a timer: a delayed match does not extend the wait past its own deadline, and an event accepted at or after the anchor never matches. A zero timeout is due at acceptance itself, so no event can settle it and only the timeout can. Every correlation pair the registration declared must appear in the matched event’s correlation metadata with the same value; the event may carry more pairs than the registration declared.

A match records the matched event’s payload as an ordinary successful step result and schedules the continuation discovery in the same transaction that removes the registration and fences the pending timeout, so a redelivered match or timeout can only find a settled step. The payload is chosen by the event’s sender, not by the waiting run, so a match whose payload the durable record cannot carry — the step record’s value bound, or the run’s replay budget — settles the wait as an accepted sdk.invalid_result step failure naming the limit instead: the workflow learns immediately that the matched payload could not be handed to it, and one unrecordable match neither strands the run until its timeout nor aborts the event’s fan-out page. A settlement no record can carry at all leaves the wait outstanding for its own timeout, because the event must still complete. Registration itself is refused when the run’s replay budget cannot carry the wait’s mandatory timeout outcome, so an exhausted run never commits an unfinishable registration and its anchor. A wait that reaches its timeout records the accepted sdk.timeout step failure and its continuation the same way. Stop intent fences a pending wait and removes its registration in the transaction that commits the stop, so a cancelled or timed-out run never settles it, and a settled wait is never part of an unwind: the engine refuses a compensable wait declaration.

Child invocations

A declaration may be a durable child invocation: the same stable step identity with the invoked function of the same application, the exact input the child receives, and an optional relative invoke timeout. The accepting transaction records the invocation with the step and materializes the child through the same materialization an event trigger uses — one run record, list entry, location, creation history, initial discovery item, and deadline timer — so the parent step, the child’s existence, and the child’s initial work are one durable decision and the two creation paths cannot drift apart.

The child’s identity is derived from durable facts, never minted per declaration: it is a digest over the child’s full storage scope, the parent run, and the step. The materialization refuses an existing record whose recorded invocation provenance or scope disagrees, so a lost acknowledgement, a rediscovery, a replay, or a restart can only ever name the same child; a repeated declaration of the same step keeps the recorded invocation and its child, and a declaration that disagrees with the recorded one is a conflict.

The child is an ordinary run in every respect: its own discovery, attempts, replay, compensators, and terminal outcome. It is bounded by the tightest of the invoked function’s declared run timeout, the invoke timeout anchored at acceptance, and the parent’s remaining deadline, and the durable record names the bound that applied. A zero invoke timeout is due at acceptance itself: the child exists durably, but its first authorization refuses instead of authorizing, so no effect of the child can run before its timeout settles it, and it settles its parent’s step with the sdk.timeout category. The engine refuses an invocation that names no function, one the parent’s pinned manifest generation does not declare, a compensable declaration, a declaration whose retry policy asks for more than one attempt (the child run is the retry unit, and the parent’s recorded policy must not disagree with what the engine does), one whose step record the parent’s remaining replay budget cannot carry together with the small failure that would settle it, and a chain deeper than the documented depth limit.

A child’s terminal transition schedules the settlement of its parent’s step as one durable work item of its own, so a chain of stopping ancestors is settled one level per dispatch instead of recursively inside a single transaction and every transaction stays bounded by its own run’s records. The settlement records the child’s terminal outcome on the parent’s step — its output, its accepted failure, or the server-authored category of a cancelled or timed-out child — and the parent then continues exactly as it does after any other server-owned step. What the parent observes is the outcome the child terminalized with, so a child’s failure reaches its parent only after the child’s own compensation finishes and a parent never continues while its child is still recovering. A settled invocation is never part of an unwind: the engine refuses a compensable invocation declaration, so an invoke step can never have a compensator.

Stop intent propagates one level per transaction and it propagates intent, not termination. A parent’s stop enqueues one durable intent item for its child; the child’s own decision wins when it is already stopping or terminal; the intent fences every new forward authorization of the child — and its pending timers and waits, exactly as a directly stopped run’s are fenced — without cutting off an authorized effect or the child’s own child; and the parent cannot terminalize before its child does. The reason is read from the parent’s committed stop intent rather than carried in the item, so a parent’s cancellation and a parent’s deadline reach the child as the same reason and a redelivered intent propagates the same one. The child’s stop intent is not prompt termination: a child may still hold an authorized effect, a durable wait, or its own child, and it stays Cancelling until every one of those settles.

Event emissions

A declaration may be a durable event emission: the same stable step identity with the exact event name, the exact payload bytes, and the routing metadata the accepted event carries. The accepting transaction accepts the event through the same transactional event acceptance every submitted event uses — the ordinary event record, the workspace-scoped idempotency claim, the fingerprint of the caller-controlled fields, the workspace list entry, and the first fan-out obligation — and settles the declaring step with the acceptance receipt in the same transaction, so no committed state holds an accepted event without the step that emitted it, or a settled emission step without its event.

The emitted event’s identity is derived from durable facts, never minted per declaration: it is a domain-separated digest over the full storage scope, the run, and the step, and the idempotency claim the acceptance uses is derived from the same facts with its own domain separator. The claim is written in the emission’s own internal namespace, distinct from the public submission’s, so a caller who recomputes the derived key cannot pre-claim it and make the emission adopt the caller’s event, and the acceptance refuses a returned identity that disagrees with the derived one before anything is recorded. A lost acknowledgement, a rediscovery, a replay, or a restart therefore re-derives the same logical event: a repeated declaration of the same step keeps the recorded emission and answers with its receipt, and it still leaves the run a continuation discovery, so a declaration delivered on fresh discovery work can never strand the run on a completed item. A declaration that disagrees with the recorded one — a different event name, payload, routing metadata, or step declaration — is a conflict rather than a second event. A step record that is absent is a fresh declaration, and the durable idempotency claim is what keeps the identity when the step record is not there: an identical repeat replays the original acceptance, and a divergent repeat under the same derived claim is refused with the ordinary idempotency conflict.

The payload is the exact event payload bytes, carried as the portable bytes value and recorded without interpretation; the server refuses a payload that is not bytes rather than reinterpreting it. The routing metadata is the caller-supplied string-keyed, string-valued correlation metadata the ordinary event submission carries: it is empty when the workflow declares none, it is never inherited from the run’s own triggering event, and it is part of both the accepted event record and the declaration equality, so an emission cannot silently satisfy a durable wait the workflow did not declare.

The acceptance is what settles the step, and the accepted result is the receipt — the event’s identity, and never the submission disposition, so a deduplicated acceptance cannot change the workflow’s result. The engine then continues the run exactly as it does after any other server-owned step, and the workflow never waits for the event’s fan-out, trigger runs, or matching waits: acceptance is not delivery. The engine refuses a compensable emission declaration, an unnamed event, an over-limit event name, a payload that is not bytes, and a declaration whose step record the run’s replay budget cannot carry together with its receipt. A retry policy that asks for more than one attempt is refused rather than ignored, because the acceptance is not retried by its parent: there is no attempt for a second one to describe. A settled emission is never part of an unwind, and a stop that fences a run’s unresolved work never rolls back an accepted event or its recorded receipt.

Direct invocation

An operator or a tool may start one function directly with an input, without the input being broadcast to every binding that listens for an event name. A direct invocation is an addressed event: it is accepted through the same transactional event acceptance as any submitted event, so it has the same acknowledgement, idempotency, routing-generation pinning, durable fan-out obligation, and workspace event list entry, and the run it creates receives its input exactly as a triggered run does.

The accepted event carries a target, the application and function it is addressed to, and the reserved name ryusen/function.invoked. The ryusen/ prefix is reserved for events the server authors: a public submission naming it is refused, so a caller cannot submit an addressed event through the broadcast path or make a broadcast event look addressed. The payload is the exact input bytes, recorded without interpretation, and the event carries no routing metadata.

The idempotency key of an invocation is scoped by workspace and its own function.invoke operation, so it cannot collide with an event.submit key. Its fingerprint covers the target, the payload, and the caller-supplied timestamp. An identical retry returns the original event and its expansion state; reuse of the key with a different target or payload is the ordinary idempotency conflict and mutates nothing.

Fan-out of an addressed event evaluates only its target application in the pinned routing generation, and only the targeted function. When that application’s manifest in the generation declares the function, the expansion records one match under the reserved binding identifier invoke and creates the run with the deterministic identity derived from the workspace, the event, the application, and that binding, exactly as a trigger match would. The function does not need a trigger binding of its own. When the generation does not reference the application, or its manifest does not declare the function, the expansion records a durable no-match that names which, and completes: the invocation is acknowledged and explained rather than lost or silently retargeted at a newer manifest. An addressed event never evaluates trigger bindings and never matches a durable wait, because it is addressed to one function rather than broadcast.

The created run is an ordinary run. Its source event is the invocation event and its trigger binding is invoke; its deadline comes from the function declaration in the pinned manifest; and its input is the payload bytes, so a workflow decodes a directly invoked input and a triggered one the same way.

Forward execution

Discovery and callback execution are separate work kinds. Discovery against the selected eligible function either completes the function or declares its next unresolved step. The declaration transaction records whether compensation currently exists before targeted execution begins. That fact is diagnostic and does not gate future unwind eligibility.

A successful result is immutable for the logical step ID. Duplicate submission of the same accepted result is idempotent. A conflicting result, stale generation, or stale lease is rejected and retained as diagnostic attempt data without replacing the accepted result.

Retryable failures atomically complete the current physical work item and create a distinct delayed successor with the next application-attempt ordinal and deterministic backoff. The logical operation ID remains stable across those physical items. Exhaustion durably records a terminal failure for the logical step and creates discovery work. Replay reproduces that failure at the matching step.run; it does not dispatch the callback again. If current code handles the failure, discovery may declare fallback work or complete the run. If the failure reaches the workflow entry adapter, the resulting uncaught-failure transaction marks the run failed and starts compensation. If current code no longer emits the failed step, normal discovery makes it Obsolete.

An authorized effectful attempt that loses contact without proving its external outcome becomes Unknown; operator resolution may accept a supplied result, mark it failed, or explicitly retry it. At or after the run deadline the first two still settle the attempt under TimedOut stop intent, while an explicit retry is refused as deadline_elapsed. Discovery loss is retried because its protocol contract forbids external effects outside step.run callbacks.

Definition blocking and recovery

The SDK returns definition_blocked when one discovery pass encounters the same step ID twice. The transaction records one active blocker keyed by run and reason, including the duplicate ID and optional source metadata, and indexes the blocked run by logical function. It invalidates unresolved discovery work and does not create or authorize the second callback. Repeated observations of the same conflict update diagnostics and probe history without appending repeated failure events or changing accepted step results.

RunningDefinitionBlockedrediscover current codeDefinitionBlocked clearedCancelling duplicate_step_idpreferred release arrivestimer/manual probecancel/timeoutconflict persistsvalid pass

The first registration of a newly preferred compatible connected release wakes blocked runs for each function it advertises. HTTP endpoints and unversioned workers do not provide trustworthy code-change identity, so their blockers use a capped low-frequency delayed probe. An operator may request immediate rediscovery in either mode. Wake and manual requests are deduplicated against existing discovery work.

While blocked, the engine authorizes no new forward callback work. A discovery pass that still reports a conflict remains blocked. Any non-blocked response atomically clears the blocker and follows its ordinary transition using normal memoization: accepted step results are reused, and only a newly unique unresolved ID creates callback work. Cancellation or timeout may move the run directly from DefinitionBlocked to normal Cancelling settlement and compensation; the definition conflict itself never triggers compensation.

The run’s history says what happened to the conflict without repeating it as a failure. The pass that first reports it appends definition-blocked; an operator retry appends definition-retry-requested; a pass that still reports a conflict appends definition-still-blocked and updates the blocker’s last observation and count, or replaces the blocker when it names a different step; and the pass that clears it appends definition-unblocked before the entries of its ordinary transition, which carry the pass. Replaying an already accepted emission appends emission-replayed rather than pretending that it accepted the event again. A stop clears the blocker and its index entry in the stop’s own transaction.

Operator resolution

Every resolution request names the exact attempt ID and fencing generation, carries an idempotency key, and contains exactly one command: supplied success, declared failure, or explicit retry. One transaction compare-and-sets the attempt from unresolved Unknown. The transaction stores the idempotency key, command fingerprint, resolution, history entry, and successor state or work together. A supplied settlement of an expired forward attempt also materializes the TimedOut stop intent the deadline implies, so the timeout and the settlement are one durable decision with one recorded response.

An identical repeat returns the recorded response. Reusing an idempotency key with a different command conflicts. Any different resolution after the attempt settles also conflicts. A late worker result competes on the same unresolved record; exactly one result or operator command commits, and losing late results remain diagnostic stale submissions.

attempt generation G is Unknownoperator commandrepeated HTTP calllate result from worker Gone CAS on (attempt, G):exactly one durable resolutionexplicit retry transactionstale diagnostic,no state mutationsettle G+1 andcommit target outcomesettle G as RetryAuthorizedfence Gcreate work for G+1 late result from Gresult from G+1

Resolution successors are normative:

Run phase Supplied success Declared failure Explicit retry

Running

Complete the forward step and create rediscovery work.

Apply the forward retry policy; exhaustion records terminal step failure and creates rediscovery.

Resolve the old attempt as RetryAuthorized, increment physical attempt and fencing generation, retain the stable operation ID, and create exactly one work item.

Running at or after the deadline

Materialize TimedOut, complete the forward step, and snapshot the unwind instead of creating rediscovery.

Materialize TimedOut and settle the failure without any forward retry or rediscovery.

Refuse as deadline_elapsed; nothing is mutated.

Cancelling

Complete the step, include it in the eventual unwind, and create no rediscovery.

Resolve settlement as failed and create no forward retry.

Refuse; stop intent forbids new forward work. The refusal is deadline_elapsed when the committed outcome is TimedOut, and retry_forbidden otherwise.

Compensating

Complete the compensation target and advance the unwind.

Apply the compensation retry policy; exhaustion terminalizes as CompensationFailed.

Resolve the old attempt as RetryAuthorized, increment physical attempt and fencing generation, retain the compensation operation ID, and create exactly one work item for the current target.

An unresolved compensation attempt ignores the forward deadline: the deadline stops forward progress, and recovery has its own attempt policies. Refusals are decided in one order, so a client can act on the distinction: the request itself, then a recorded idempotency key, then the exact unresolved attempt and fencing generation, and only then the deadline policy. A settled or stale attempt is therefore stale at any clock, and a recorded command replays even when the state that would now refuse it has since committed.

Explicit retry is not a claim that the previous effect failed. It is durable operator acceptance of possible duplication. Incrementing the fencing generation prevents generation G from committing workflow state after G+1 is created, while the stable operation ID lets an idempotent external system recognize both physical attempts as one logical operation.

A generation has one immutable settlement: worker result, operator-supplied result, declared failure, or RetryAuthorized. Resolving G as RetryAuthorized does not consume the logical target’s eventual result; it authorizes G+1 to settle that target. If G+1 also becomes Unknown, another explicit command may repeat the same transition to G+2. After any restart, durable settlement and generation records reconstruct the same fence: an older generation can append diagnostic stale-result data but cannot mutate authoritative target state.

Cancellation settlement

The stop-intent transaction records Cancelled or TimedOut as the eventual execution outcome, clears any active definition blocker, and prevents discovery from creating new forward work. It fences leased or queued forward attempts that have not committed start authorization and discards authorized repeatable discovery. Effectful attempts already authorized retain a settlement token derived from their operation ID and generation; this narrow token permits their result to commit without reopening forward scheduling, and the run remains Cancelling until every token resolves.

If no authorized effectful attempt requires settlement, the stop transaction immediately snapshots completed steps. It creates the first compensation item for a nonempty unwind or transitions directly to Terminal with NotRequired(EmptyUnwind). In particular, cancellation or timeout after run creation but before first discovery authorization executes no callback and terminalizes atomically with an empty unwind.

event fan-out transactionRunning + queued discoveryfence discovery + TerminalCancelled/TimedOut + NotRequired(EmptyUnwind) cancel/timeout before authorization
                         stop intent commits
                                  |
        +-------------------------+-------------------------+
        |                                                   |
        v                                                   v
 unstarted or repeatable                        authorized effectful attempts
 fence generation                                    retain settlement token
        |                                                   |
        v                               +-------------------+-------------------+
     settled                            |                   |                   |
                                        v                   v                   v
                                     success             failure             Unknown
                                        |                   |                   |
                                        +-------------------+----------+--------+
                                                                       |
                                                        all tokens resolved?
                                                                       |
                                                        no <-----------+----------> yes
                                                        |                          |
                                                        v                          v
                                                     blocked              snapshot unwind
                                                                         /             \
                                                                    nonempty           empty
                                                                       |                 |
                                                                       v                 v
                                                                Compensating          Terminal

The result transaction normal-reads the settlement record, attempt, and run phase. A valid late success records the completed step but schedules no rediscovery. A structured failure records the reported failure. Lease expiry or lost transport after authorization records Unknown. A late result may replace Unknown only while its settlement token remains unresolved; an operator result and late worker result use the common resolution CAS, so exactly one commits. The operator may supply success or declare failure with the risk described in RFD 1, but cannot redispatch forward work after stop intent commits.

A repeated stop intent with the same outcome is idempotent while the run is Cancelling: the first one already committed the outcome and forbade new forward work, so a second request appends no history and mutates no run or state record. A different stop outcome cannot replace the one that committed, and the run timer stays consumed. A stop that retains a settlement token records the same replayable receipt a terminalizing stop does, so a redelivered timer replays the durable response instead of failing on work the first transition consumed.

A transport that proves an authorized effect never began — a connection that failed before the endpoint answered, or a correlated pre-start rejection — is the one case where an authorized effectful lease may leave the unresolved index without settling. The engine reconciles it in the run’s own transaction: while the run can still dispatch the attempt the same physical attempt is requeued without consuming an attempt ordinal, and under stop intent the attempt is fenced, the run releases ownership of it, and the settlement gate runs. The attempt provably never executed, so it is not part of the unwind. Requeueing it instead would leave the run Cancelling holding work no dispatcher can prepare, because preparing a forward target requires a running run.

Compensation order is snapshotted only after every effectful settlement token resolves, thereby including successful work that completed after cancellation was requested. While any token is Unknown, the run remains Cancelling with durable intervention work and no compensation item.

A stop request that retains no settlement token settles the run in the same transaction, whether or not completed steps exist. It fences the work the run currently owns and then either snapshots a nonempty unwind with its first compensation item or terminalizes an empty one as NotRequired(EmptyUnwind). A stop therefore never refuses work it could have compensated, and the snapshot is the same one a settlement-token stop reaches once its last token resolves.

Compensation

Entering compensation snapshots every completed step in reverse completion order from durable history, regardless of its declaration-time compensation bit. The engine processes one item at a time. Before each target, current code is asked to execute its compensator; target_not_found records a durable skip and continues. A provided compensator uses independent attempts and retry limits. An accepted success, terminal failure, or skip is not reopened by later code changes.

settled failure, cancellation, or timeoutsnapshot completed stepsTerminal + NotRequiredCompensating + first target emptynonempty

The snapshot transaction never creates a Compensating run without durable next work. An empty plan appends a CompensationNotRequired history record with reason EmptyUnwind and transitions directly to Terminal. Successful execution uses NotRequired with reason ExecutionSucceeded without creating a snapshot.

After the last target, any durable skip produces PartiallyCompensated, even when every target skipped. With no skips, at least one executed compensator and all successes produce Compensated. A permanent failure produces CompensationFailed and stops the unwind.

Compensation work uses a separate work kind so later flow-control implementations can reserve capacity and avoid deadlocking recovery behind forward work.

Lease or transport loss after compensation start authorization becomes Unknown and stops the unwind without consuming the retry policy. A late result or operator resolution settles that attempt before the engine may retry a confirmed failure or advance past a confirmed success.

Queue and fan-out benchmarks

RFD 3 owns the common harness, topology metadata, and storage metrics. This RFD defines the engine workloads it must drive. Local and CI profiles use pure engine components and SQLite to catch algorithmic or revision-relative regressions; optional local FDB verifies conflict semantics but does not establish production capacity.

The workload matrix includes:

  • steady and burst event acknowledgement followed by zero, one, and many trigger matches across increasing applications and bindings;

  • fan-out expansion with increasing chunk sizes, including restart between chunks and a backlog accumulated while dispatch is stopped;

  • ready and delayed queue insertion, scanning, lease acquisition, renewal, completion, expiry, and recovery with increasing dispatcher counts;

  • mixed server-owned, repeatable discovery, forward-effect, and compensation work so one domain cannot hide another’s saturation;

  • uniform and deliberately skewed workspace, run, vesting-time, and priority distributions;

  • same-key idempotency contention and workspace routing activation during event ingestion, reported separately from the ordinary unique-event path;

  • dispatcher or database-process loss followed by lease and backlog recovery.

Every run records acknowledgement-to-fan-out completion and ready-to-lease p50, p95, and p99 latency; accepted events, materialized runs, and leased work per second; transaction attempts and conflicts by transition; transaction bytes; lease scan amplification; shard load distribution; backlog depth; and recovery time. Results separate offered load from accepted throughput and report errors or timeouts rather than dropping them from latency distributions.

The structural performance requirements are:

  • event acknowledgement remains constant transaction work regardless of total matching triggers because it creates one fan-out obligation;

  • each fan-out transaction is bounded by its chunk and retrying it creates no duplicate logical run;

  • ordinary queue insertion and completion mutate no global key shared by all work items;

  • increasing backlog does not require a full ready-work scan per lease;

  • a lease conflict or expired dispatcher cannot strand work, and recovery rate is measurable independently of new workload;

  • shard skew and dispatch saturation are visible rather than masked by aggregate throughput.

event loadacknowledgement commit latencyconflicts/retriesbounded fan-out expansion latencyruns + tx bytessharded ready work lease latencyscan + shard skew

The production-like FDB profile runs the same matrix from external load generators against the named multi-machine topology in RFD 3. Its explicit throughput, tail-latency, recovery, and regression gates must be recorded before the hosted queue is called production ready. A benchmark on an unspecified machine, single-process SQLite, or single-machine FDB is never substituted for that gate.

Recovery invariants

After any process crash:

  • every non-terminal run has durable discoverable work, a live lease, a timer, a pending event wait registration with its timeout item, an active definition blocker with a release wake, probe timer, or manual retry path, or an explicit Unknown reason requiring intervention;

  • every DefinitionBlocked run has exactly one active blocker, no authorized forward callback work, and an index entry under its logical function;

  • every Running or DefinitionBlocked run with an effective deadline has one generation-fenced RunTimeout item until timeout or terminalization consumes it;

  • every Cancelling run has unresolved settlement work or has atomically created its first compensation item or terminalized an empty unwind as NotRequired;

  • every Compensating run has a live lease, durable discoverable work, or an explicit Unknown intervention blocker;

  • every acknowledged event has complete fan-out or durable discoverable fan-out work for its immutable workspace routing generation;

  • no SDK work is assigned unless its workspace and application exactly match the credential-derived scope of the connected registration or HTTP endpoint;

  • no accepted result exists without the transition that consumes it;

  • no committed state transition relies on a notification having been delivered;

  • each generation settles at most once, and after generation G+1 is created no result from generation G may mutate the logical target;

  • terminal runs create no new forward work.

These invariants are tested by injecting a stop after every transaction commit and reopening the store.

Non-goals

The initial queue does not implement multi-tenant fairness, concurrency limits, throttling, debounce, batching, CEL waits (a durable event wait’s correlation map is the whole filter language; expressions and predicates are not part of it), cron, distributed dispatch, or FDB sharding. Those features must later preserve the transactional transition model.

Implementation

State records, transitions, dispatcher, and crash tests are tracked in RFD 4 implementation checklist.