Adopt backend-issued claim write tokens and opt-in fenced session streams - #4792
Open
adityavkk wants to merge 2 commits into
Open
Adopt backend-issued claim write tokens and opt-in fenced session streams#4792adityavkk wants to merge 2 commits into
adityavkk wants to merge 2 commits into
Conversation
…ed session streams
…ssion streams With fencedSessionStreams on, the append path forwarded the claim's write token to the backend but still required the in-memory ClaimWriteTokenStore to know it first. That store is process memory: a claim adopted by another server instance, or by this one before a restart, was refused 401 while the backend still considered it live — the process-local write authority electric-sql#4286 describes, one layer up. In fenced mode the backend minted the token and verifies it atomically with the append, so the server now defers to it and does not consult the store on the append path; with the flag off, validation is unchanged. The token stays opaque (spec §4): nothing inspects its shape. Because a backend without the extension ignores Write-Fence and creates the stream unfenced, a client that requires fencing must check for the `Write-Fence: true` echo before treating the stream as fenced [WF-02]. Fenced creates now HEAD for the echo (the client library does not surface the PUT response) and fenced forks check the PUT response, and both fail instead of yielding a stream the fence never protects. The pin against DurableStreamTestServer flips from "silently unfenced" to "refused".
adityavkk
marked this pull request as ready for review
September 2, 2026 00:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets the Durable Streams backend, rather than the Agents Server's in-memory store, be the mint and the write authority for claim-scoped write tokens, and adds an opt-in mode in which entity session streams are write-fenced by the backend itself. Addresses the distribution gaps called out in #4286 and gives the pull-runner path from #4299 the same write-authority story as webhooks.
Problem
#4286 notes that claim write tokens are currently held in memory on one server: validity is lost on restart, not shared between instances in a distributed deployment, and not coupled to the claim lease or heartbeat. It also notes the enforced invariant is only "while an active claim is present, only that claim token may write" — not yet "only the current claimed worker may write", because validation happens solely in the forwarding server while the Durable Streams backend accepts whatever the server forwards under its own identity.
Approach
The Durable Streams Write Fencing extension (spec; proposed upstream as durable-streams/durable-streams#406) has the backend mint a write token per subscription claim and deliver it as additive
write_tokenfields on the existing §7 surfaces: the webhook wake notification, the pull-claim response, and every non-done ack. This PR makes the Agents Server adopt those tokens wherever they appear, and — in the opt-in fenced mode — defer the append-time decision to the backend that minted them:internal-router): awrite_tokenon the subscription webhook body is held per wake and adopted when the runtime's claim callback mints — the runtime receives the backend's token as itswriteToken.runners-router, RFC: Run Agents Anywhere — Local Runners, Worker Pools, and Sandboxes #4299): awrite_tokenon the claim response is held the same way, so the runner's claim callback adopts it identically.internal-router+ runtimeprocess-wake): the backend re-mints the token on each ack (its TTL tracks the claim lease); the server adopts the refresh and surfaces it aswriteTokenon the heartbeat response, and the runtime picks it up so appends from long-running activations keep a live token. The store keeps the immediately-previous token valid across a same-consumer refresh so an append already in flight is not rejected; a different consumer's claim still evicts everything.fencedSessionStreamsserver option, envELECTRIC_AGENTS_FENCED_SESSION_STREAMS): entity session streams are created (and forked) withWrite-Fence: true, and runtime appends forward the presented token asWrite-Tokenplus aWrite-Fence: trueclass assertion when proxied to the backend. In this mode the backend verifies the token atomically with the append and the server does not consult its in-memory store on the append path at all — so a claim adopted by another instance, or by this instance before a restart, is honoured exactly as the backend honours it. Write authority is durable across server restarts, shared across instances, and lease-coupled, which is the mechanism Agents auth follow-up: claim-scoped write tokens, shared-state auth, and distributed validity #4286 asks for without building a second distributed store inside the Agents Server. The assertion also means a lost token is a loud 401 downstream rather than a silent write under the forwarding server's identity.Write-Fenceand creates the stream unfenced, so a client that requires fencing must check for theWrite-Fence: trueecho before treating the stream as fenced (spec WF-02). WithfencedSessionStreamson, a session-stream create or fork that the backend does not echo fails, instead of yielding a stream the fence never protects.Key invariants
fencedSessionStreamsoff (the default), no new headers are sent anywhere and the in-memory store validates appends as today.Compatibility
write_tokenfields are optional on every surface, and a base Durable Streams server ignoresWrite-Fence/Write-Tokenentirely. Fenced mode therefore requires a backend implementing the extension, and says so at create time rather than degrading silently (pinned againstDurableStreamTestServer).internal-router.ts): token adoption is data-driven while fencing is opt-in, so a pre-adoption runtime pointed through this server at a token-minting backend loses write authority once heartbeat refreshes rotate the token past the store's one-refresh grace — upgrade runtimes before the backend starts minting; every other skew combination degrades to today's behaviour.Verification
New coverage: backend-token adoption vs minted fallback in the store and through both wake-delivery routes; no delivered-token entry left behind by wakes auto-acked or rejected before any claim; previous-token grace across a heartbeat refresh and eviction on takeover; the append path deferring to the backend for a token the store does not know with the flag on, rejecting it with the flag off, and never asserting the class on shared-state; fenced create/fork refusing a backend that does not echo the fence; an end-to-end heartbeat refresh through a real server; and the runtime adopting a refreshed token for subsequent producer appends.
Files changed
packages/agents-server/src/claim-write-token-store.ts:mintaccepts a backend-issued token; delivered-token holding (recordDelivered/takeDelivered); previous-token grace on same-consumer refresh.packages/agents-server/src/stream-client.ts: Write Fencing header constants (spec-linked);write_tokenon the claim response;writeFenceopt-in oncreate/fork, checked against the backend's echo.packages/agents-server/src/routing/internal-router.ts: acceptwrite_tokenon webhook bodies and heartbeat ack responses; adopt at the mint site (a delivered token is held only once its wake is actually forwarded, so auto-acked or rejected wakes leave no entry).packages/agents-server/src/routing/runners-router.ts: hold the claim response'swrite_tokenfor the runner's claim callback.packages/agents-server/src/routing/stream-append.ts: in fenced mode forwardWrite-Token+Write-Fenceand let the backend judge the token; otherwise validate against the store as today.packages/agents-server/src/{entity-manager,runtime,standalone-runtime,server}.ts: thefencedSessionStreamsoption, threaded to the manager with an env fallback; fenced create/fork of session streams.packages/agents-runtime/src/process-wake.ts: refreshwriteTokenfrom heartbeat responses..changeset/agents-backend-issued-write-tokens.md(patch, both packages).