Skip to content

Adopt backend-issued claim write tokens and opt-in fenced session streams - #4792

Open
adityavkk wants to merge 2 commits into
electric-sql:mainfrom
adityavkk:feat/agents-backend-write-tokens
Open

Adopt backend-issued claim write tokens and opt-in fenced session streams#4792
adityavkk wants to merge 2 commits into
electric-sql:mainfrom
adityavkk:feat/agents-backend-write-tokens

Conversation

@adityavkk

@adityavkk adityavkk commented Sep 1, 2026

Copy link
Copy Markdown

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_token fields 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:

  • Webhook wakes (internal-router): a write_token on 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 its writeToken.
  • Pull-runner claims (runners-router, RFC: Run Agents Anywhere — Local Runners, Worker Pools, and Sandboxes #4299): a write_token on the claim response is held the same way, so the runner's claim callback adopts it identically.
  • Heartbeats (internal-router + runtime process-wake): the backend re-mints the token on each ack (its TTL tracks the claim lease); the server adopts the refresh and surfaces it as writeToken on 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.
  • Opt-in fencing (fencedSessionStreams server option, env ELECTRIC_AGENTS_FENCED_SESSION_STREAMS): entity session streams are created (and forked) with Write-Fence: true, and runtime appends forward the presented token as Write-Token plus a Write-Fence: true class 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.
  • No silently unfenced streams: a backend without the extension ignores Write-Fence and creates the stream unfenced, so a client that requires fencing must check for the Write-Fence: true echo before treating the stream as fenced (spec WF-02). With fencedSessionStreams on, a session-stream create or fork that the backend does not echo fails, instead of yielding a stream the fence never protects.

Key invariants

  • When the backend supplies no token, behaviour is byte-for-byte today's: the store mints its own token exactly as before (stated in a comment at the adoption site).
  • With fencedSessionStreams off (the default), no new headers are sent anywhere and the in-memory store validates appends as today.
  • Command and shared-state appends never carry the fenced-class assertion; only entity-stream appends do.
  • The write token is never part of the runner notification body; it reaches the runtime only through the claim/heartbeat callback, as today.
  • The write token stays opaque to the server (spec §4): nothing here inspects its shape.

Compatibility

  • Default off; no configuration change is required anywhere.
  • The new write_token fields are optional on every surface, and a base Durable Streams server ignores Write-Fence/Write-Token entirely. Fenced mode therefore requires a backend implementing the extension, and says so at create time rather than degrading silently (pinned against DurableStreamTestServer).
  • Chronicle is a Durable Streams server implementing the extension, usable as a backend for the fenced mode.
  • One version-skew caveat (documented as a compatibility matrix at the adoption site in 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.
  • Delegation (an agent spawning, sending, or signalling as itself) still authorises against the in-memory store in every mode; making that backend-verified as well needs a verify surface on the backend (tracked at [P2] Write Fencing: a capability-verify route so gateways can check a live claim without writing (WF-29/WF-30) adityavkk/chronicle#191) and is left for a follow-up.

Verification

pnpm --filter @electric-ax/agents-server exec vitest run test/claim-write-token-store.test.ts test/stream-append.test.ts test/subscription-webhooks-routing.test.ts test/runners-router.test.ts test/stream-client.test.ts test/stream-client-fork.test.ts
pnpm --filter @electric-ax/agents-server exec vitest run test/server-claim-write-token.test.ts    # docker backend
pnpm --filter @electric-ax/agents-runtime exec vitest run test/process-wake.test.ts
pnpm --filter @electric-ax/agents-server test     # full suite
pnpm --filter @electric-ax/agents-runtime test    # full suite
pnpm --filter @electric-ax/agents-server typecheck && pnpm --filter @electric-ax/agents-runtime typecheck
pnpm --filter @electric-ax/agents-server stylecheck && pnpm --filter @electric-ax/agents-runtime stylecheck
GITHUB_BASE_REF=main node scripts/check-changeset.mjs   # ✅ covers @electric-ax/agents-runtime, @electric-ax/agents-server

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: mint accepts 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_token on the claim response; writeFence opt-in on create/fork, checked against the backend's echo.
  • packages/agents-server/src/routing/internal-router.ts: accept write_token on 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's write_token for the runner's claim callback.
  • packages/agents-server/src/routing/stream-append.ts: in fenced mode forward Write-Token + Write-Fence and let the backend judge the token; otherwise validate against the store as today.
  • packages/agents-server/src/{entity-manager,runtime,standalone-runtime,server}.ts: the fencedSessionStreams option, threaded to the manager with an env fallback; fenced create/fork of session streams.
  • packages/agents-runtime/src/process-wake.ts: refresh writeToken from heartbeat responses.
  • Tests as described above; .changeset/agents-backend-issued-write-tokens.md (patch, both packages).

…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
adityavkk marked this pull request as ready for review September 2, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants