feat(sidecar): protocol 2 — file by type, a cursor feed, and bounded history #2

Merged
whitlocktech merged 1 commits from feat/phase-3-read-path into main 2026-09-16 16:37:28 +00:00
Member

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.

The rule is lifted out of the spawned event loop into a Filing enum specifically so it can be asserted about rather than living in five arms of a match no test can reach.

A frame with no usable type 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 and nobody notices until they wonder why the database is large.

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 to an operator as a website showing nothing while the game is plainly up.

It caught exactly that within three seconds of first running, unplanned: a protocol 1 plugin still live on a retired rig dialled in, and its server.hello went straight into the counter.

Boards, and an in-place migration

boards generalises protocol 1's single server_state row. A database made by protocol 1 is migrated in place: two indexed columns added by a guarded ALTER, and the old board 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. There is a test that builds a protocol 1 database by hand and opens it with this build.

GET /feed — the ingest cursor

Oldest first, strictly after an id, with lastId and more. 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. A module installed today against a month-old sidecar wants what happens next, and the two intentions must not be separated by whether somebody typed a parameter.

Retention

[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 CI this repo did not have

Phases 1 and 3 both wrote cargo fmt, clippy -D warnings and a test suite, and both ran them by hand. A guard nothing invokes is a guard whose state nobody knows, and the repository that gets released had nothing gating it.

44 tests pass; clippy clean at -D warnings.

Spec: docs/rust-link/PROTOCOL.md §8 (RunicGateway/docs#255).


  • AI-assisted: written with Claude Code (Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

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. The rule is lifted out of the spawned event loop into a `Filing` enum specifically so it can be asserted about rather than living in five arms of a `match` no test can reach. ## A frame with no usable `type` 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 and nobody notices until they wonder why the database is large. 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 to an operator as *a website showing nothing while the game is plainly up*. **It caught exactly that within three seconds of first running**, unplanned: a protocol 1 plugin still live on a retired rig dialled in, and its `server.hello` went straight into the counter. ## Boards, and an in-place migration `boards` generalises protocol 1's single `server_state` row. A database made by protocol 1 is migrated in place: two indexed columns added by a guarded `ALTER`, and **the old board 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. There is a test that builds a protocol 1 database by hand and opens it with this build. ## `GET /feed` — the ingest cursor Oldest first, strictly after an id, with `lastId` and `more`. 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. A module installed today against a month-old sidecar wants what happens next, and the two intentions must not be separated by whether somebody typed a parameter. ## Retention `[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 CI this repo did not have Phases 1 and 3 both wrote `cargo fmt`, `clippy -D warnings` and a test suite, and both ran them **by hand**. A guard nothing invokes is a guard whose state nobody knows, and the repository that gets released had nothing gating it. 44 tests pass; clippy clean at `-D warnings`. Spec: `docs/rust-link/PROTOCOL.md` §8 (RunicGateway/docs#255). --- - [x] AI-assisted: written with Claude Code (Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-16 13:40:51 +00:00
feat(sidecar): protocol 2 — file by type, a cursor feed, and bounded history
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m14s
06fa5d7330
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
whitlocktech merged commit 3aabd2befe into main 2026-09-16 16:37:28 +00:00
whitlocktech deleted branch feat/phase-3-read-path 2026-09-16 16:37:29 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Rust-Link#2
No description provided.