summaryrefslogtreecommitdiffstats
path: root/docs/adr/0004-protocol-v5.md
blob: c74ad83656365f96a1c4ce3887831695f797a63b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# 0004 — Protocol v5: frames, envelopes, time and commit–reveal

Status: accepted, 2026-11-08

## Context

The current wire is newline-delimited text over TLS. It has served the TUI and
GUI well, but four problems are now worth fixing before a browser gateway is
built on top:

- `LineBuffer` grows without bound if a peer never sends a newline, and there
  is no explicit maximum message size.
- A reply is assumed to answer the request that is currently pending. A late
  reply, a retry or a second in-flight request can clobber state.
- There is no idempotency: a gateway retry after a timeout can charge a bet
  twice.
- The crash multiplier is anchored to each frontend's local clock, and the
  crash point is only "trust the server".

The web gateway makes the first three worse (it retries and multiplexes) and
the browser makes the fourth visible (a page and the desktop can disagree).

## Decision

This is one wire change and one `MAJOR` release (`5.0.0`); there is no
compatibility mode.

### Framing

- Every packet is `[u32 length][payload]`, length big-endian.
- `payload` is UTF-8 text, at most 64 KiB. A larger declared length is a
  protocol error and the connection closes; buffers never grow past one frame.
- The payload has no trailing newline.

### Envelope

- `REQ <id> [idem=<token>] <command>` — client request. `<id>` is a `u64`
  chosen by the client, monotonically increasing on the connection.
- `RES <id> <body>` — the reply to that request.
- `EVT <seq> <body>` — a server-initiated event. `<seq>` is a `u64`,
  incremented once per event on the connection.

The existing text grammar is the `<command>` and `<body>`; `HELLO 5.0.0`,
`PLAY_START crash 50`, `GAME_RESULT ...` keep their meaning.

`HELLO` is the first framed request. The server ignores every other command
before a successful handshake. The reply is
`HELLO <version> latest=<v> url=<url> time=<unix_ms>`.

### Correlation and idempotency

- The client keeps a request id until its `RES` arrives. Replies are routed by
  id, never by "the next line".
- The server keeps a bounded 256-entry FIFO of `(id, payload, reply)` on each
  connection. A repeated id with the same payload returns the stored reply
  without executing the command again. A repeated id with a different payload
  returns `ERROR Request id reused with a different command`.
- The cache is per connection and in memory. A reconnect is a new game session
  by design; durable idempotency is deferred.
- `idem=<token>` is optional metadata for logs and for a future cross-
  connection cache. The request id is the cache key.

### Clock and ticks

- `HELLO` and `PONG` carry the server's Unix time in milliseconds. `PING`
  replies `PONG <unix_ms>`.
- Clients keep an offset between server time and their own clock. The offset is
  for display and interpolation only; the server clock settles a cash-out.
- A crash start carries `started_at=<unix_ms>` and `commit=<hex>`.
- While a crash round runs, the server pushes at most one
  `EVT <seq> crash.tick session=<id> t=<unix_ms> m=<multiplier>` per 100 ms.
  The client interpolates between ticks and may keep rendering locally.
- `CRASH_BUSTED` remains an event, now with the reveal attached.

### Commit–reveal crash

- At round start the server draws 32 random bytes as the round seed and sends
  `commit = sha256(seed)` in hex. It also sends the crash point's start time.
- The crash point is a pure function of the seed:
  `u = u64_be(seed[0..8]) / 2^64`, then
  `crash_point_from_uniform(u)`. The seed never reaches the client before the
  round ends.
- When the round ends (`CRASH_STATE` after cash-out, or `CRASH_BUSTED`), the
  server includes `seed=<hex>`. A client verifies `sha256(seed) == commit` and
  recomputes the crash point. The UI can show "verified".
- This proves the crash point was fixed before the player acted. It does not
  prove the seed was not ground to a favourable value; a hash chain with client
  entropy is the documented next step if that matters.

## Consequences

- The TUI, GUI, server and gateway all move to v5 together; a v4 peer is
  refused at `HELLO`.
- `LineBuffer` is replaced by a bounded `FrameBuffer`. Existing newline-based
  tests and helpers are rewritten around frames.
- `ClientCore` allocates request ids and routes replies by id. Its `Effect`
  carries the id, so an adapter writes the envelope and hands the `RES` back
  with the same id.
- Crash rendering can be smoother and consistent across frontends, and the
  crash result is independently checkable from the revealed seed.
- The per-connection cache is deliberately small and forgets on reconnect; a
  retry after a reconnect is a new action, not a replay.

## Alternatives considered

- **JSON or binary frames**: cleaner types or smaller payloads, but a rewrite
  of every parser for no scale need. Text inside a bounded frame keeps logs
  readable and the change small.
- **Newline-delimited frames with a maximum line length**: still needs a
  separate envelope and does not bound the buffer as cleanly; a declared length
  is simpler.
- **Durable idempotency keyed by `(user, idem)`**: survives reconnects, but the
  gateway owns retries on one connection and a reconnect is a fresh session.
  Deferred.
- **Server seed plus client seed without a commit round-trip**: the server
  sees the client seed before committing and can bias the pair; a half-measure
  is worse than an honest per-round commitment.
- **Hash-chain epoch**: stronger anti-grinding, but requires persistent chain
  state and a restart story. Deferred.