Synvael/docs/adr/0011-authority-stream-for-server-pushed-state.md
Serkyo cae434d227
Some checks are pending
CI / Rust Check & Lint (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Lua Lint & Format (push) Waiting to run
CI / LFS Pointer Guard (push) Waiting to run
docs(workspace): rewrite the subsystem notes and ADRs
2026-08-06 22:52:57 +02:00

4.7 KiB

0011. A dedicated authority stream for server-pushed state

  • Status: Accepted
  • Date: 2026-07-31

Context

Because our simulation is strictly server-authoritative (see ADR-0004), there is an entire category of network traffic that the client never actually asks for: state the server just decides to push on its own cadence. Simulation snapshots will eventually make up the bulk of this, but periodic server diagnostics were the first concrete instance we hit.

We could have crammed this traffic into two existing streams, but both were a terrible fit:

  • The control stream (stream 0) handles handshakes and disconnects. It is strictly request/response and effectively one-shot per connection. Shoving a recurring push onto it mixes one-time lifecycle negotiation with steady-state spam, meaning a burst of pushed state could delay a time-sensitive disconnect notice sitting in the exact same ordered stream.
  • The chunk stream (stream 3) is bidirectional and handles massive frames (up to a 1 MiB cap). QUIC handles head-of-line blocking on a per-stream basis. If a tiny, highly time-sensitive state push gets queued directly behind a 500 KB chunk delivery, it completely inherits that chunk's awful latency. That is precisely the coupling that separate streams are designed to avoid.

Stream assignment acts as a hard wire contract shared by both peers (StreamLayout rigidly fixes the IDs), and changing one is a protocol break. Because of this, we need to make this decision exactly once, ahead of the massive snapshot work that will rely on it, rather than discovering we need it later and breaking the protocol.

Decision

Server-pushed authoritative state travels on its very own, unidirectional-in-practice stream: StreamLayout::authority (stream 2). It carries shared::protocol::authority::AuthorityMessage.

  • The stream is strictly server => client only. Absolutely nothing the client sends belongs on it. When client input arrives, it will get its own dedicated stream.
  • AuthorityMessage is an enum. Because of this, any new pushed payloads are simply added as variants rather than requiring brand new streams. ServerStats is the first variant, and simulation snapshots will eventually join it.
  • Frames use our existing length-prefixed postcard codec, capped safely at MAX_AUTHORITY_FRAME_LEN (64 KiB). This is well above what a fixed-shape diagnostics record needs, and it safely limits how much memory a malformed length prefix can trick a peer into allocating.
  • The async/sync bridge strictly follows the pattern we established for chunk delivery in ADR-0010. The simulation loop holds an AuthoritySink, which is a synchronous, non-blocking handle wrapping a tokio MPSC sender. This ensures neither the server nor client crate ever has to explicitly name a tokio type.
  • If we attempt a send on a departed connection, it simply logs at debug level and drops. The simulation loop cannot act on a disconnected client anyway, and since pushed state is by definition unsolicited, failing to deliver it is never an actual error condition for the sender.

Consequences

  • The latency of pushed state is now completely independent of chunk delivery volume. Even if a client is pulling its initial region at maximum bandwidth, it still receives its state snapshots perfectly on time.
  • Adding a brand new pushed payload is as simple as adding an enum variant. It requires zero new streams to negotiate on either peer and zero StreamLayout changes.
  • The stream layout now explicitly commits four IDs (control 0, reserved 1, authority 2, chunk LOD0 3). Trying to reassign any of these will demand a full PROTOCOL_VERSION bump.
  • Loss and ordering semantics are strictly per-stream. Authority messages are perfectly ordered relative to each other, but completely unordered relative to chunk deliveries. If something requires a snapshot to be interpreted against a specific delivered chunk, it must carry its own correlation data (like a tick number or chunk version) rather than lazily relying on arrival order across streams.
  • The current sink is fire-and-forget and unbounded. While this is perfectly fine for a low-rate diagnostics push, sending massive snapshots at tick rate will absolutely require a bound and a drop policy. We cannot allow a slow client to grow the server's queue without limit. This is a known follow-up requirement before snapshots officially ship.
  • Because diagnostics are on the authority stream rather than beside it, they are subject to the exact same server-authoritative framing: the client strictly reports what the server measured, never what it inferred locally. See docs/diagnostics.md for more details.