diff options
Diffstat (limited to '')
| -rw-r--r-- | docs/adr/0004-protocol-v5.md | 119 |
1 files changed, 119 insertions, 0 deletions
diff --git a/docs/adr/0004-protocol-v5.md b/docs/adr/0004-protocol-v5.md new file mode 100644 index 0000000..c74ad83 --- /dev/null +++ b/docs/adr/0004-protocol-v5.md | |||
| @@ -0,0 +1,119 @@ | |||
| 1 | # 0004 — Protocol v5: frames, envelopes, time and commit–reveal | ||
| 2 | |||
| 3 | Status: accepted, 2026-11-08 | ||
| 4 | |||
| 5 | ## Context | ||
| 6 | |||
| 7 | The current wire is newline-delimited text over TLS. It has served the TUI and | ||
| 8 | GUI well, but four problems are now worth fixing before a browser gateway is | ||
| 9 | built on top: | ||
| 10 | |||
| 11 | - `LineBuffer` grows without bound if a peer never sends a newline, and there | ||
| 12 | is no explicit maximum message size. | ||
| 13 | - A reply is assumed to answer the request that is currently pending. A late | ||
| 14 | reply, a retry or a second in-flight request can clobber state. | ||
| 15 | - There is no idempotency: a gateway retry after a timeout can charge a bet | ||
| 16 | twice. | ||
| 17 | - The crash multiplier is anchored to each frontend's local clock, and the | ||
| 18 | crash point is only "trust the server". | ||
| 19 | |||
| 20 | The web gateway makes the first three worse (it retries and multiplexes) and | ||
| 21 | the browser makes the fourth visible (a page and the desktop can disagree). | ||
| 22 | |||
| 23 | ## Decision | ||
| 24 | |||
| 25 | This is one wire change and one `MAJOR` release (`5.0.0`); there is no | ||
| 26 | compatibility mode. | ||
| 27 | |||
| 28 | ### Framing | ||
| 29 | |||
| 30 | - Every packet is `[u32 length][payload]`, length big-endian. | ||
| 31 | - `payload` is UTF-8 text, at most 64 KiB. A larger declared length is a | ||
| 32 | protocol error and the connection closes; buffers never grow past one frame. | ||
| 33 | - The payload has no trailing newline. | ||
| 34 | |||
| 35 | ### Envelope | ||
| 36 | |||
| 37 | - `REQ <id> [idem=<token>] <command>` — client request. `<id>` is a `u64` | ||
| 38 | chosen by the client, monotonically increasing on the connection. | ||
| 39 | - `RES <id> <body>` — the reply to that request. | ||
| 40 | - `EVT <seq> <body>` — a server-initiated event. `<seq>` is a `u64`, | ||
| 41 | incremented once per event on the connection. | ||
| 42 | |||
| 43 | The existing text grammar is the `<command>` and `<body>`; `HELLO 5.0.0`, | ||
| 44 | `PLAY_START crash 50`, `GAME_RESULT ...` keep their meaning. | ||
| 45 | |||
| 46 | `HELLO` is the first framed request. The server ignores every other command | ||
| 47 | before a successful handshake. The reply is | ||
| 48 | `HELLO <version> latest=<v> url=<url> time=<unix_ms>`. | ||
| 49 | |||
| 50 | ### Correlation and idempotency | ||
| 51 | |||
| 52 | - The client keeps a request id until its `RES` arrives. Replies are routed by | ||
| 53 | id, never by "the next line". | ||
| 54 | - The server keeps a bounded 256-entry FIFO of `(id, payload, reply)` on each | ||
| 55 | connection. A repeated id with the same payload returns the stored reply | ||
| 56 | without executing the command again. A repeated id with a different payload | ||
| 57 | returns `ERROR Request id reused with a different command`. | ||
| 58 | - The cache is per connection and in memory. A reconnect is a new game session | ||
| 59 | by design; durable idempotency is deferred. | ||
| 60 | - `idem=<token>` is optional metadata for logs and for a future cross- | ||
| 61 | connection cache. The request id is the cache key. | ||
| 62 | |||
| 63 | ### Clock and ticks | ||
| 64 | |||
| 65 | - `HELLO` and `PONG` carry the server's Unix time in milliseconds. `PING` | ||
| 66 | replies `PONG <unix_ms>`. | ||
| 67 | - Clients keep an offset between server time and their own clock. The offset is | ||
| 68 | for display and interpolation only; the server clock settles a cash-out. | ||
| 69 | - A crash start carries `started_at=<unix_ms>` and `commit=<hex>`. | ||
| 70 | - While a crash round runs, the server pushes at most one | ||
| 71 | `EVT <seq> crash.tick session=<id> t=<unix_ms> m=<multiplier>` per 100 ms. | ||
| 72 | The client interpolates between ticks and may keep rendering locally. | ||
| 73 | - `CRASH_BUSTED` remains an event, now with the reveal attached. | ||
| 74 | |||
| 75 | ### Commit–reveal crash | ||
| 76 | |||
| 77 | - At round start the server draws 32 random bytes as the round seed and sends | ||
| 78 | `commit = sha256(seed)` in hex. It also sends the crash point's start time. | ||
| 79 | - The crash point is a pure function of the seed: | ||
| 80 | `u = u64_be(seed[0..8]) / 2^64`, then | ||
| 81 | `crash_point_from_uniform(u)`. The seed never reaches the client before the | ||
| 82 | round ends. | ||
| 83 | - When the round ends (`CRASH_STATE` after cash-out, or `CRASH_BUSTED`), the | ||
| 84 | server includes `seed=<hex>`. A client verifies `sha256(seed) == commit` and | ||
| 85 | recomputes the crash point. The UI can show "verified". | ||
| 86 | - This proves the crash point was fixed before the player acted. It does not | ||
| 87 | prove the seed was not ground to a favourable value; a hash chain with client | ||
| 88 | entropy is the documented next step if that matters. | ||
| 89 | |||
| 90 | ## Consequences | ||
| 91 | |||
| 92 | - The TUI, GUI, server and gateway all move to v5 together; a v4 peer is | ||
| 93 | refused at `HELLO`. | ||
| 94 | - `LineBuffer` is replaced by a bounded `FrameBuffer`. Existing newline-based | ||
| 95 | tests and helpers are rewritten around frames. | ||
| 96 | - `ClientCore` allocates request ids and routes replies by id. Its `Effect` | ||
| 97 | carries the id, so an adapter writes the envelope and hands the `RES` back | ||
| 98 | with the same id. | ||
| 99 | - Crash rendering can be smoother and consistent across frontends, and the | ||
| 100 | crash result is independently checkable from the revealed seed. | ||
| 101 | - The per-connection cache is deliberately small and forgets on reconnect; a | ||
| 102 | retry after a reconnect is a new action, not a replay. | ||
| 103 | |||
| 104 | ## Alternatives considered | ||
| 105 | |||
| 106 | - **JSON or binary frames**: cleaner types or smaller payloads, but a rewrite | ||
| 107 | of every parser for no scale need. Text inside a bounded frame keeps logs | ||
| 108 | readable and the change small. | ||
| 109 | - **Newline-delimited frames with a maximum line length**: still needs a | ||
| 110 | separate envelope and does not bound the buffer as cleanly; a declared length | ||
| 111 | is simpler. | ||
| 112 | - **Durable idempotency keyed by `(user, idem)`**: survives reconnects, but the | ||
| 113 | gateway owns retries on one connection and a reconnect is a fresh session. | ||
| 114 | Deferred. | ||
| 115 | - **Server seed plus client seed without a commit round-trip**: the server | ||
| 116 | sees the client seed before committing and can bias the pair; a half-measure | ||
| 117 | is worse than an honest per-round commitment. | ||
| 118 | - **Hash-chain epoch**: stronger anti-grinding, but requires persistent chain | ||
| 119 | state and a restart story. Deferred. | ||
