summaryrefslogtreecommitdiffstats
path: root/docs/PROTOCOL-IDEAS.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/PROTOCOL-IDEAS.md')
-rw-r--r--docs/PROTOCOL-IDEAS.md170
1 files changed, 170 insertions, 0 deletions
diff --git a/docs/PROTOCOL-IDEAS.md b/docs/PROTOCOL-IDEAS.md
new file mode 100644
index 0000000..3991a84
--- /dev/null
+++ b/docs/PROTOCOL-IDEAS.md
@@ -0,0 +1,170 @@
1# Server ↔ client transport ideas
2
3Status: design notes, written 2026-11-08. Items 1–5 were adopted (with the
4differences recorded in `docs/adr/0004-protocol-v5.md`): correlation ids,
5per-connection idempotency, clock sync + ticks, commit–reveal crash, and
6length-prefixed framing. Items 6–7 remain future work.
7
8These came out of reviewing zagan-bet's transport, but they were prompted by a
9*different* app. Nothing here is a backlog item for this repository. The
10zagan-bet paths are included because that is where the patterns were worked
11out; treat the ideas as portable.
12
13zagan-bet's current shape, for context:
14
15- TLS with TOFU over the certificate's SPKI fingerprint, trust decision after
16 the handshake and before credentials (`src/client/transport.rs`,
17 `src/tofu.rs`).
18- Newline-delimited text lines over the TLS stream (`src/protocol.rs`).
19- `HELLO MAJOR.MINOR` lockstep before auth (`src/version.rs`).
20- Request/reply plus exactly one server-initiated line, `CRASH_BUSTED`
21 (`src/bin/server/handler.rs`, `ServerCore::poll`).
22- `ClientCore` is I/O-free; frontends send `Command` and render `View`
23 (`src/client/core.rs`). The GUI owns the stream in `gui/src/net.rs`.
24
25Each idea below lists the wire shape, where it would attach, and the cost.
26
27## 1. Correlation IDs, so a single `Pending` can go away
28
29One request in flight, and pushes distinguished by string prefix
30(`expects_push`, `CRASH_BUSTED`, `src/client/core.rs:622`). A late reply can
31race a pushed bust; the code already has a special case for it
32(`src/client/core.rs:211`). An envelope removes the whole class:
33
34```
35REQ 42 idem=f3a1... INFO
36RES 42 OK balance=1200 account=...
37EVT seq=913 crash.tick {"session_id":"...","server_ms":837412,"multiplier":1.42}
38```
39
40`REQ`/`RES` correlate by id; `EVT` is connection-scoped and never answers
41anything. The core keeps a map of in-flight requests instead of one `Option`,
42and "a stale reply must not clobber the result" becomes a generic rule rather
43than a crash-specific branch. Also allows pipelining, e.g. clock probes while a
44`HISTORY` is in flight, without app-layer head-of-line stalls. Keep line
45framing for now; just prepend tokens.
46
47## 2. Idempotency keys on every balance mutation
48
49A request can be applied server-side and the reply lost. Today that is safe
50only because nothing retries automatically; the moment a client retries or
51resumes, it can double-debit.
52
53```
54REQ 43 idem=9c2e... PLAY_START crash 50
55```
56
57Server keeps `seen_requests (idem PRIMARY KEY, user, reply, created_at)`. The
58replay check, the debit, and the row insert sit in the same transaction as the
59existing reservation (`database::reserve_play`). A duplicate returns the stored
60reply and never re-executes. TTL-sweep at startup next to
61`recover_orphaned_reservations`. Scope by user, not connection, so a resumed
62session cannot replay someone else's key.
63
64## 3. Clock sync and a real tick stream
65
66zagan-bet advances timed games on read timeouts (`POLL_INTERVAL`,
67`src/bin/server/handler.rs:7`) and the client draws crash locally from an
68`Instant`. Payouts are correctly server-clock authoritative, but the client
69does not know its offset. Two parts:
70
71**Clock sync**, Cristian/NTP style over the existing connection:
72
73```
74REQ 7 idem=... TIME t0=837412
75RES 7 OK t0=837412 t1=837498 # t1 = server monotonic ms
76```
77
78`offset = t1 - (t0 + t2) / 2`; keep the minimum-RTT sample over a sliding
79window. Render timed games from `server_now`.
80
81**Ticks** at 10–20 Hz, monotonic `seq`, no reply semantics:
82
83```
84EVT seq=914 crash.tick {"session_id":"...","server_ms":837520,"multiplier":1.44}
85```
86
87Coalesce in the writer: replace an unqueued tick, never drop a settlement.
88That also enables lag compensation — render at `server_now - presentation_delay`
89(e.g. 150 ms) so the displayed number never exceeds what the server accepts at
90the moment of a click. A cleaner fairness story than trusting the client clock.
91
92## 4. Commit–reveal instead of a hidden `Option<f64>`
93
94zagan-bet already withholds the crash point (`CrashState::crash_point`,
95`src/crash.rs`). Make it verifiable:
96
97```
98RES 51 OK COMMIT 7f3a... 4b9c... # sha256(server_seed), round id
99...round runs...
100EVT seq=980 crash.busted {"seed":"<server_seed>","crash_point":2.37,...}
101```
102
103Player supplies a client seed; the server echoes it. Crash point derives from
104`HMAC-SHA256(server_seed, client_seed)` → 52 bits → `[0, 1)` → the existing
105`crash_point_from_uniform`. Verification is a pure function and belongs in a
106unit test, not a network test. A hash chain lets the server pre-commit every
107future round at session start; that is stronger than needed in most cases.
108
109## 5. Framing: length prefix, then maybe QUIC
110
111Weaknesses in newline framing:
112
113- `LineBuffer` grows without bound; a client that never sends `\n` is a memory
114 DoS. Reject oversized frames *before* allocating.
115- `from_utf8_lossy` silently rewrites corrupt input. Newline framing is only
116 safe while `serde_json` keeps escaping newlines — an implicit dependency.
117
118```
119[u32 LE len][u8 proto_version][u8 kind][payload]
120```
121
122This allows a move to `postcard`/CBOR later without touching the read loop.
123The larger swing is QUIC (`quinn`): separate streams so a slow `HISTORY`
124cannot block ticks, 0-RTT resumption, connection migration for NAT rebinds
125mid-round, and unreliable datagrams for ticks where latest-wins beats
126retransmission. zagan-bet already uses rustls on the client, so this would also
127drop OpenSSL from the server build. Cost: UDP reachability, TLS 1.3 only, a
128rustls bump.
129
130## 6. Resume tokens and a blocking reader thread
131
132`AUTH` returns an opaque HMAC-signed resume token plus a session id;
133`RESUME <token> since=<seq>` replays missed `EVT`s from a bounded per-session
134buffer. Turns disconnect from "forfeit everything" into a policy choice; a
135server-clocked round is trivially resumable. In zagan-bet this contradicts the
136AGENTS rule that disconnect = forfeit, so it would need an explicit carve-out
137and a grace window.
138
139Separately, both ends poll with timers: the server's 50 ms read timeout, and
140the GUI flipping `SO_RCVTIMEO` to 1 ms every 8 ms in
141`TlsStream::read_available_line`. A dedicated blocking reader thread feeding an
142`mpsc` removes the per-call timeout mutation, `expects_push`, and
143`POLL_INTERVAL`; the handler becomes a `select` over inbound lines, a tick
144deadline, and shutdown. Still no async runtime.
145
146## 7. Small ones worth batching
147
148- `HELLO 5.0 caps=idem,tick,commit` with server-side intersection instead of
149 strict MAJOR.MINOR lockstep for additive changes. Keep MAJOR for framing.
150- Structured errors: `ERR code=INSUFFICIENT_FUNDS retryable=0 msg=...` so the
151 frontend can tell "reconnect" from "show text".
152- `set_nodelay(true)` plus an app-level `PING` deadline; a half-open TCP
153 connection is otherwise noticed only on the next write.
154- Per-connection token-bucket rate limiting. Every inbound line is logged, so a
155 chatty client is also a log-flood/disk problem.
156- A protocol fuzzer (`proptest`/`cargo-fuzz`) asserting the server never panics
157 and `balance >= 0` for arbitrary input, plus transcript-replay tests.
158
159## If only three
160
1611. Correlation IDs + idempotency keys — small, kills two bug classes.
1622. Clock sync + coalesced ticks — turns a special-case push into a generic
163 event channel.
1643. Commit–reveal — cheap, and the strongest fairness story for a betting
165 server.
166
167The envelope plus idempotency is the least invasive pair: it touches
168`src/protocol.rs`, `ServerCore::handle_message`, and
169`ClientCore::handle_reply`, and can sit behind a `caps=` flag so version
170lockstep stays intact.