Protocol 4 gives the guild board a real member list instead of the member
*count* that was all Protocol 2 could express. `guild.roster` carries the set;
`guild.leave` is forwarded but deliberately not projected.
The roster lives in its own `members` column rather than as a field folded into
`json`. That column holds the verbatim `guild.update` line, so a roster write
into it would clobber the snapshot — name, abbreviation, leader, online count —
that `guild.update` owns. Two writers across two columns of one row means both
stay plain upserts: neither reads the other's value first, so there is no
read-modify-write and no ordering requirement between the two kinds. `GET
/guilds` folds the roster back in as `roster` at read time.
`guild.leave` gets no board arm on purpose. The shard re-emits `guild.roster`
whenever the member set changes, so the board self-corrects within one sweep,
and keeping the delta out of the projection is what keeps the sidecar a
forwarder rather than a thing that maintains state.
This is also the repo's first store migration, and the reason it needed one:
`SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but cannot add a
column to a table that already exists. Every schema change up to and including
Protocol 3.0 happened to add whole tables, so `ALTER TABLE` appears nowhere in
this repo's history and the gap was invisible. `guilds.members` is the first
column added to an existing table, so without a mechanism the column would
simply never reach an installed sidecar and every roster write would fail.
The counter is SQLite's own `PRAGMA user_version` — an integer in the database
header, so it costs no table and cannot drift from the file it describes. Each
step runs in a transaction together with the bump recording it, so a step lands
completely or not at all. A database written by a *newer* sidecar warns and
continues rather than failing: every step is additive, so a newer schema has only
columns an older reader ignores, and refusing to start would turn rolling the
binary back — a recovery path — into a dead end.
A migration failure aborts startup, which was already the behaviour and is the
right one: a half-migrated store answers the website with confusing partial data,
and the shard dials *out*, so a sidecar that refuses to start never stalls the
game.
store.rs had no tests before this. The six added here cover the upgrade path that
matters (an existing pre-Protocol-4 database gains the column and lands at the
current version), that a restart re-running the migration is a no-op, that a
roster does not clobber the snapshot, that the two writers work in either order,
and that a guild with no roster yet has no `roster` key at all — "not known" and
"known to be empty" must not be conflated, or a website renders an empty roster
as fact.
Also gates PRs into `edge`, not just `main`. This workstream lands ten phases
there, and gating only the `main` hop would run these checks for the first time
at the cutover. The precedent and the reasoning are already in
RunicGateway/installer's copy of this workflow.
Refs: docs/website/TEAMS.md Part 12 Phase 1
Co-Authored-By: Claude <noreply@anthropic.com>
This repo had no pull_request workflow at all — release.yml runs only
after merge, so its `cargo fmt --check` gate was the first thing to see
new code, and an unformatted commit killed the release job before it
could build, tag, or publish (run for 2301c57).
Add pr-checks.yml, mirroring release.yml's gates in the same order so a
green PR implies the release clears its own gates:
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
One job, not three: bootstrapping the toolchain costs more than the
checks, so parallel jobs would pay it three times for no wall-clock win.
Cargo registry and target/ are cached on Cargo.lock. The crate is
already clean at `-D warnings`, so clippy starts green.
Co-Authored-By: Claude <noreply@anthropic.com>