Skip to content

State Ownership and State Lanes

Execution topic table of contents · Spec table of contents · Previous: 05. Payload Ownership And Codec · Next: 07. Serial Executor Layers

This document defines the mechanism by which a component guards its own mutable state — a primitive contract guaranteeing that only one turn touches that state at a time. Every language runtime must follow this contract. Observable behavior such as ordering, timeouts, and error codes is owned by other documents, and the rules this document defines do not change that observable behavior.

1. State Ownership Overview

A component owns mutable state — fields and collections. This document defines the rules for the mechanism that guarantees only one piece of code touches that state at a time: the criteria for classifying which state needs which primitive, the guarantees that primitive must provide, and how to structure code so reentrancy cannot arise.

This document does not define when a handler executes or yields its place to another handler; that contract is owned by Handler Turn And Execution Gate. Ownership and copying of a message on its way from the socket to the handler is owned by Payload Ownership And Codec. The rules here concern the mechanism that protects a component's internal state, not the ordering, timeouts, or error codes an application observes.

2. Terminology — State Lane versus Application/Lifecycle Lane

This document's state lane is the execution unit a component uses to own its mutable state. Every piece of code that reads or writes that component's state runs only on this lane.

A state lane serializes state access, while the application lane and lifecycle lane classify handler work. Handler turn and execution gate §7 owns the per-execution-object scope of handler FIFOs. That execution object is distinct from the MeshNode Owner identified by Location Store authority.

State lane (this document) Application lane / lifecycle lane (02 §7)
Unit One component (for example, one binding table, one catalog) One execution object (such as Spot/Actor) — see 02 §7
Purpose Lets only one turn at a time touch that component's state Orders the work an owner processes by priority
What it holds The pieces of code that read or write that component's state Business payloads and timer callbacks (application), or join/leave/relocation/lifecycle control (lifecycle)
Admission/priority None — executes strictly in FIFO arrival order, with no count/byte caps or priority between lanes Present — count/byte caps, an owner occupancy time budget, and a lifecycle priority rule
Owning document This document 02 §7

Assigning a state lane to a component and admitting the handler of its Spot/Actor to the application lane or lifecycle lane are decisions at different layers. While a handler execution is waiting its turn on the application lane, the state that handler touches may be serialized on a different component's state lane.

3. The Prohibited Shape

A state lane rules out the following shape — taking a snapshot of state inside an exclusive section such as a lock, releasing that section, and then deciding on the far side of an async boundary using that snapshot.

Entry entry;
lock (_gate) { if (!_entries.TryGetValue(key, out entry)) return; }   // released here
await SendAsync(entry.Route);                                         // acts on a value that may already be stale

This shape is not a mistake — it is a result the mechanism forces. A lock cannot wrap an await. So code that guards state with a lock while also needing to act asynchronously on that state ends up, without exception, in the shape "take a snapshot, release the lock, then act on that value." By the time the code reaches a point where the lock can be reacquired (after the asynchronous work completes), some other turn may already have changed the same state in the meantime, so the snapshot is structurally stale.

A state lane removes this shape entirely. Because there is no release point inside a lane turn, the code that reads the state and the code that settles the decision stay in the same turn.

// contract pseudocode, not the real API — the real signatures are owned by each language interface.
// inside the turn, read the state and finalize the decision so it cannot be undone.
var claim = await lane.Run(() =>
    entries.TryGetValue(key, out var entry)
        ? entry.ClaimRoute()      // generation and ownership settled inside this turn
        : null);                  // a plain map. not locked

if (claim is null) return;
await SendAsync(claim);           // the long external call happens outside the turn — §4, §5

What matters is what leaves the turn. The prohibited shape above carries a copy of mutable state out and assumes it is still correct. This one builds a claim whose validity was fixed inside the turn, so what the claim refers to does not change even if the state does.

Do not wait for a long external call inside a turn. A state lane turn goes as far as reading the state and settling the decision — waiting for an external operation to complete inside a turn is what §4 forbids.

4. State Classifications and How to Tell Them Apart

A component's mutable state falls into one of the following classifications. Once classified, the mechanism that guards it follows mechanically.

Classification Criteria Mechanism
C1 — pure lookup registry A single map whose operations end at get/add/remove, with no invariant spanning any other field or collection. Splitting the exclusive-access block into two atomic operations does not break any invariant Concurrent map (the language's thread-safe map implementation)
C2 — cross-invariant state An invariant spans multiple collections/fields, or a decision made here continues into asynchronous action that follows from it State-lane ownership + a plain map (unlocked)
C3 — atomic counter/flag Only an integer increment, a flag check, or a single reference swap Atomic operations (the language's atomic increment/compare-and-swap/volatile reference)

When a component mixes all three, C2 wins — the strongest requirement decides the mechanism for the whole component. Moving only part of a component to a lane while leaving the rest on a concurrent map or a separate lock lets the cross-invariant violation that C2 exists to prevent reappear at that boundary.

// contract pseudocode, not the real API — the real signatures are owned by each language interface.
class RouteRegistry            // C1 — lookup/add/remove only, no invariant spanning anything
{
    ConcurrentMap<RouteKey, Route> routes;
}

class BindingTable             // C2 — the two maps reference each other; changing one breaks the invariant
{
    ZLinkStateLane lane;
    Map<ActorId, SessionId> actorToSession;   // plain map
    Map<SessionId, ActorId> sessionToActor;   // the lane provides exclusivity

    Task Bind(actorId, sessionId) => lane.Run(() =>
    {
        actorToSession[actorId]   = sessionId;   // both change in one turn
        sessionToActor[sessionId] = actorId;
    });
}

class SendCounter              // C3 — increments only
{
    Atomic<long> sent;
}

Do not replace C2 with a concurrent map. A concurrent map makes only the individual operation on one map atomic. An invariant spanning multiple collections/fields is not preserved by per-map atomicity — atomicity is limited to each individual map, and the invariant breaks with it.

Do not replace C2 with a different exclusive-access primitive (a semaphore, for example). Swapping the exclusive-access primitive from a lock to a semaphore leaves fully intact the shape that §3 rules out — all that changes is a number (the lock count went down), while the structure of "release the exclusive section, then act on a snapshot" is unchanged.

Distinguishing State Protection from Serializing an Operation Protocol

A semaphore, socket gate, or dispose gate that runs an entire external asynchronous operation one at a time carries a different responsibility from the state lane that guards a component's mutable state. A gate can stay an operation-protocol primitive only when it meets all of the following conditions.

  • What the gate protects is the start/end of an external resource operation, or exact-once disposal — it does not separately own any part of C2 state.
  • Whatever generation, identity, or ownership the operation needs is settled inside the gate before the operation starts.
  • After releasing the gate, code acts on a Task, a reservation, a seal token, or a resource whose sole ownership was transferred to it — not a mutable state snapshot.
  • The completion path returns that same ownership exactly once.

Waiting for a state lane's completion from inside an operation-protocol gate is allowed. The opposite direction — waiting, from inside a state lane's turn, for that gate to be acquired or for a long-running operation to complete — is not. Allowing both directions creates a deadlock in which each side waits on the other's completion.

This exception is not permission to split C2 state across multiple locks. Fields and collections that share the same invariant are still owned by a single state lane.

5. What a State Lane Guarantees and Requires

A state lane guarantees the following.

  • Only one turn executes at a time. Two turns on the same lane never execute concurrently.
  • It is FIFO. Work executes in the order it entered the lane. There is no front-of-queue insertion.
  • It is non-reentrant. If code already executing on that lane's turn tries to enter the same lane again, that attempt is refused.
  • It does not lock the collections it owns. A collection holding state the lane owns stays an ordinary structure — it is the lane's single execution, not a lock, that provides exclusivity, so the collection itself has no need to be thread-safe.
// contract pseudocode, not the real API — the real signatures are owned by each language interface.
interface ZLinkStateLane
{
    Lane Current { get; }        // the lane currently running, or null
    bool IsOnLane { get; }       // is the caller on this lane's turn
    Task<T> Run<T>(work);        // run in one turn of this lane and return the result
    bool TryPost(work);          // enqueue without awaiting; false if closed
    void ThrowIfReentrant();     // already on this lane → throw right here
    Task Close();                // stop accepting, finish what was accepted
}

These six carry the same name and the same meaning in all four languages (§7).

A reentrancy violation must be detected as an exception, not a deadlock. Why reentrancy has no way to proceed shows up once the sequence is spelled out concretely.

  1. Public method A of some component is already running on the lane's turn.
  2. A's body calls public method B of the same component.
  3. B also needs to enter the same lane to touch the state, so it waits for "the turn currently running on this lane" to finish.
  4. But the turn currently running on this lane is A itself — the one that called B.

B is waiting for A to finish, and A cannot finish its own turn until B finishes — which comes down to A waiting for itself. No other turn can break this wait, so it never ends.

Left as a hang, this stops the server silently. Nothing in a log or stack trace names the call that caused it, so finding the cause means combing through an execution dump taken at that moment. The lane must instead end this at the exact call site where the reentrant call happened, as an exception. Raising an error right there that names which lane was re-entered means the stack trace points straight at the offending code.

Completion Signals and the Blocking-Compatible Boundary

A lane work item's completion signal must run the caller's continuation only after that lane's current-ownership marker has been released. In a language where the completion API can run a dependent continuation inline on the completing thread, do not complete the signal directly inside the lane-current scope. Post the completion to a scheduler outside that scope, or force the continuation to run asynchronously.

An existing synchronous surface may block-wait on a state lane's completion only as a compatibility boundary, and only when all of the following conditions hold.

  • The submitted lane item does not reacquire any external gate it currently holds.
  • Every completion signal the lane item raises runs its continuation asynchronously.
  • There is a contract requiring state registration/capture to complete before that synchronous surface returns, or a recorded reason why the public synchronous signature cannot change.

If even one of these three conditions cannot be confirmed, do not wait for lane completion while holding a gate. Either propagate the call path asynchronously, or separate the gate's responsibility from the lane's again.

Completion Before Return

If the original synchronous method finished registering a waiter, capturing a generation, reading a store, or claiming ownership before returning, that work must still be finished before a caller can observe the return once it moves onto a lane. Do not turn it into an asynchronous fire-and-forget post.

// contract pseudocode, not the real API — the real signatures are owned by each language interface.
Task<Reservation> Reserve(key)
{
    // a caller observing the return believes the reservation is already taken.
    return lane.Run(() => reservations.Add(key));
}

Task<Reservation> Reserve_WRONG(key)
{
    lane.TryPost(() => reservations.Add(key));   // returns before it is taken
    return pendingReservation;
}

Later stages that wait on a completion signal may stay asynchronous. The registration or capture itself is not deferred past the return.

A synchronous public contract does not become asynchronous merely because a state lane was introduced. Propagate an async signature only when the internal callers are already asynchronous and the observable contract does not change.

Dynamic control surfaces (runtime-changeable flags, diagnostics levels, and the like) are provided as dual surfaces. This mirrors the bindings completion-surface policy (the binding async-coroutine-policy spec — operations classified ASYNC still offer a sync terminal alongside): the async surface is canonical, and the sync surface is a single minimal bridge over it satisfying the three conditions above. The sync surface is only for use outside framework-owned execution contexts (handlers, lanes, turns, completion callbacks) — its audience is configuration time, operational tooling, and tests. Inside framework execution contexts, use the async surface. Do not let the languages diverge by providing only one of the two surfaces in any language.

6. Structuring so Reentrancy Cannot Arise

Reentrancy does not happen by accident. It arises structurally in three places, and each place has a prescribed structure.

Kind ① — a place where code inside the lane calls back into the same component's public surface. If one public entry point, already inside a turn admitted to the lane, calls another public method of the same component, that method also tries to enter the same lane, which is reentrancy. For such cases, use a private method that does not enter the lane. The public entry point enters the lane exactly once, and code inside the lane calls the private method's body directly.

// contract pseudocode, not the real API — the real signatures are owned by each language interface.
Task Bind(actorId, sessionId) => lane.Run(() => BindOnLane(actorId, sessionId));
Task Rebind(actorId, sessionId) => lane.Run(() =>
{
    UnbindOnLane(actorId);              // calling Unbind() would enter the lane again
    BindOnLane(actorId, sessionId);     // inside the lane, call the body directly
});

void BindOnLane(actorId, sessionId) { ... }    // does not enter the lane
void UnbindOnLane(actorId) { ... }

Kind ② — a long-running asynchronous operation started inside a lane turn inherits lane ownership. If a turn starts an operation such as a timeout, a retry, or a background loop, the context that operation runs in can inherit the marker "currently executing on this lane" as-is. When the delay ends and that operation tries to enter the same lane again, it is detected as reentrancy even though the original turn has, in fact, already ended.

At the point where such a long-running operation is started, break the flow of execution context. This break only takes effect once the asynchronous operation has actually crossed a thread transition, though. If the synchronous prefix of the async function that was called can re-enter the same lane before its first await, breaking the context flow alone is not enough — in that case, post the start of the operation itself to a separate scheduler so even the synchronous prefix runs outside the original turn. Conversely, if the first action is a genuine asynchronous delay and there is no lane reentrancy before it, breaking the context flow alone is enough.

// contract pseudocode, not the real API — the real signatures are owned by each language interface.
await lane.Run(() =>
{
    state.retrying = true;
    StartRetryLoop(key);        // the context flow is broken inside here
});

void StartRetryLoop(key)
{
    using (SuppressLaneContext())           // do not hand down "currently on this lane"
        RunDetached(() => RetryLoop(key));  // if the synchronous prefix can reach the lane,
                                            //   post the start itself to a separate scheduler
}

Kind ③ — an external callback invoked from inside a former exclusive-access section. Invoking a callback directly inside a lane turn, where that callback used to work by reentering a monitor, makes the callback re-enter the same component's public surface. Split this into three steps.

  1. In turn A, finish validation, computing the result, every state transition the original code used to finish before the callback, and a placeholder ownership claim.
  2. Invoke the callback outside the lane turn.
  3. In turn B, either replace that placeholder with the callback result's Task exactly, or settle the failure.

Do not defer to turn B a state transition the original code used to finish before the callback. A racing observer inside the callback's execution window must see the same state the original code showed "after the exclusive section ended."

Do not rely on reentrancy permitted by an exclusive-access primitive. Some languages' exclusive-access primitives (C#'s Monitor and Java's synchronized) allow nested acquisition by the same thread. A structure in which an external callback or listener invoked inside an exclusive-access section reacquires the same primitive is latent reentrancy that works only because of that allowance — moving it to a state lane turns it into a reentrancy exception, and even before it is moved, the atomicity that the exclusive access was meant to guarantee depends on the behavior of code outside that exclusive-access section. Split such cases according to Kind ③. This prohibition does not apply to nested acquisition by a private self-call that does not let control leave the component.

7. Per-Language Mapping

In .NET, Zlink.Framework.Runtime.Execution.ZLinkStateLane is the reference implementation of the state lane this document defines. State access runs as work submitted to this lane, and the collections the lane owns stay plain Dictionary instances. Reentrancy is detected, not as a hang, but as an InvalidOperationException raised immediately at the call site.

ZLinkStateLane is separate from ZLinkSerialExecutionQueue, which is used for Spot/Actor execution. ZLinkSerialExecutionQueue carries relocation sealing and lifecycle admission together, responsibilities a state owner does not need — a component that only wants to guard its state, borrowing this execution queue instead, would take on the relocation/lifecycle responsibilities that queue carries along with it. For that reason, state ownership does not use this execution queue.

When porting to another language, rather than carrying over the concrete type or language idiom as is, use that language's primitive that satisfies the same guarantees this document defines — one turn executing at a time, FIFO, immediate exception detection on reentrancy, and no locking on owned collections.

The public surface names and contracts are the exception. The following six state-lane members have the same meaning across all four languages. The first five names also match modulo spelling conversion. Because close currently has a different name in each language, it is a unification target — the table shows the currently observed names, and the target is one consistent close naming scheme.

Contract Meaning .NET java cpp node
current the lane currently executing Current current() current() current
isOnLane am I on this lane IsOnLane isOnLane() is_on_lane() isOnLane
run execute in a lane turn RunAsync() runAsync() run() run()
tryPost post without waiting TryPost() tryPost() try_post() tryPost()
throwIfReentrant throw on reentrancy ThrowIfReentrant() throwIfReentrant() throw_if_reentrant() throwIfReentrant()
close shut down DisposeAsync() closeAsync() close() dispose()

- Reentry that waits for the current turn of the same state lane is rejected at entry. The throwIfReentrant check is required regardless of build mode or previous lock implementation. Without it, the current turn waits for work queued behind itself and deadlocks.

Names and contracts for the executor layers (Spot/Actor/Session coordinators and the serial queue primitive) are set by 07. Serial executor layers.

In .NET, the lane-ownership marker uses AsyncLocal, so break that inheritance at the point a long-running operation is started with ExecutionContext.SuppressFlow. If the async function's synchronous prefix can re-enter the lane before its first await, post the start itself to a separate scheduler, for example with Task.Run.

Java does not call CompletableFuture.complete from inside a lane-current ThreadLocal scope. complete can run a dependent with no async marker inline on the completing thread. Either release the current scope before completing, or use an API that completes on a separate scheduler, such as completeAsync.

A Node.js synchronous method never runs concurrently with another callback while it finishes inside one JavaScript turn. So a synchronous surface where state access does not split across an await and that needs no public-reentrancy detection is not converted to a Promise. A state lane is needed only on the asynchronous paths where state splits across an await, and the surfaces that need reentrancy detection.

8. Verification Requirements

The following are checked using the public surface only (the return value and exceptions of work submitted to a state lane, the error a reentrant call receives, and the state observable at the moment a component method returns). Each item maps to one test.

Single execution and order

  • When different callers submit work to the same lane concurrently, no update is lost — a later read returns a value reflecting every submission.
  • Work submitted to the same lane executes in submission order.
  • Submitting a result-awaiting call to a closed lane ends immediately with an exception, and a submission that does not await a result returns a failure.

Reentrancy

  • Code already executing on that lane's turn that tries to enter the same lane again does not hang; it ends immediately with an exception at that call site.
  • A long-running operation started from a lane turn enters the same lane normally once the original turn has ended, and produces no false reentrancy exception. The same holds for a long-running operation with a synchronous prefix.
  • External callbacks run outside the turn, and the state a callback observes reflects every state transition the original code used to finish before the callback.

Completion boundary

  • Signalling completion inside a turn does not make that continuation end in a reentrancy exception; it runs normally.
  • A method with a register-or-capture-before-return contract has that registration or capture already finished at the moment a caller observes the return.
  • A call that waits for lane completion while holding an operation-protocol gate returns without hanging.

Cross-collection invariants

  • When calls that change several collections participating in one invariant arrive concurrently, reading two of those collections from outside never observes them disagreeing.

Execution topic table of contents · Spec table of contents · Previous: 05. Payload Ownership And Codec · Next: 07. Serial Executor Layers