One more thin forward: the chat titles each player has earned, as a whole
set the plugin swaps in and BetterChat reads on the chat path. perm.sync's
BetterChat styles and chat.say's delivery and format pass through untouched
like the rest of their bodies. PROTOCOL_VERSION moves to 12.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The sidecar now files a frame by its `type` and never by its `kind`. That is the
dumb-forwarder property made structural: `event` is appended to history,
`snapshot` replaces the board of its kind, `reply` is routed by `reqId`,
`control` is broadcast and kept nowhere. Ten new event kinds are no change here
at all, which is the whole point when the thing that grows fastest is the
catalogue.
A frame whose `type` this build does not know is dropped and counted, never
guessed at. Defaulting an absent one to `event` would file a BOARD as history —
the presence board appended a few thousand times, which nothing reports. The
count is on `/health` as `untyped_frames`, because the failure it diagnoses (a
plugin and a sidecar on different protocol versions, which the game link has no
handshake to catch) otherwise presents as a website showing nothing while the
game is plainly up. It caught exactly that within three seconds of first running,
against a protocol 1 plugin still live on a retired rig.
`boards` generalises protocol 1's single `server_state` row, and a database made
by protocol 1 is migrated in place: the two indexed columns are added by a
guarded `ALTER`, and the old board is carried across. Without that carry-over an
upgraded sidecar answers `204` until the game next connects, and the website
reads that as "never heard from" — losing a server it has rendered for weeks at
the exact moment somebody upgraded the bridge.
`GET /feed` is the ingest cursor: oldest first, strictly after an id, with
`lastId` and `more`. It is a separate route rather than a flag on `/events`
because one route with two orderings serves the other one to every caller that
forgets the parameter — and for the ingesting caller that means advancing its
cursor past rows it never read. Omitting `since` asks where the END is; `since=0`
is the other question entirely, and the two must not be separated by whether
somebody typed a parameter.
`[store].retain_days` (default 14) prunes events hourly. Boards are never pruned:
history grows and the present does not, and a pruned board is a server that has
never connected.
The repository also had no CI. `pr-checks.yml` runs the fmt, clippy and test
gates phases 1 and 3 have both been running by hand — a guard nothing invokes is
a guard whose state nobody knows.
44 tests pass, clippy clean at `-D warnings`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
The rust-link sidecar: it owns the loopback listener the Oxide bridge plugin
dials into, and serves the website a WebSocket feed plus store-backed reads.
Protocol 1 is deliberately three frames — server.hello, ping/pong, and one
correlated server.status — because phase 1's job is to get every seam working at
once with almost nothing in them.
What is load-bearing rather than incidental:
* The plugin is the TCP client and this process owns the listener, so a Rust
server opens no extra port. Loopback is the trust boundary on that link and
there is no token on it; the website-facing surface is the opposite, with auth
always on and a token generated and persisted on first start.
* Inbound lines are capped at 1 MiB from the start rather than after the first
large frame arrives. An over-long line is discarded and the connection stays
up: one malformed frame is not a reason to drop a link live events flow over.
* Store-backed reads answer while the game is off, which is what lets a website
render a server list during a wipe. /status is the one route that fails when
the game is down, and /server answers 204 rather than a null when the game has
never connected -- those are different answers and a client that cannot tell
them apart renders a server that does not exist.
* The two RPC failures get distinct codes. 503 means the game is down; 504 means
it is up and did not answer. Different fixes.
* rpc::REPLY_TIMEOUT is a ceiling every later command budget sits under: core
classifies a budget overrun as retryable unconditionally, so an action whose
budgetMs does not exceed it can never report retry:false.
One defect found while building, which no unit test would have caught: a
four-connection SQLite pool over :memory: hands out four separate empty
databases, because an in-memory database is per connection. It presents as
'no such table' from a random subset of queries. The pool is now capped at one
connection for an in-memory path, which is the only coherent reading of
:memory: and is what makes it usable at all.
Exercised end to end against a live Rust server: a server.hello travelled game
-> sidecar -> module -> the public website API, and killing this process left
the game untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4