feat(shard): ingest guild rosters and departures (protocol 4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s

Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:57:26 -05:00
parent 97e2fddfcd
commit 2fa4d87a40
9 changed files with 302 additions and 5 deletions

View File

@@ -45,6 +45,12 @@ const LOGGED_KINDS = new Set([
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
// Protocol 4: the departure counterpart to guild.join, and logged for the same
// reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately
// stays out: it is board state like guild.update, and it is the one fat frame on
// the wire (~69 bytes per member), so logging it would bloat shard_events with
// a full membership snapshot on every membership change.
'guild.leave',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
@@ -187,6 +193,16 @@ async function applyStateChange(event, deps) {
case 'guild.remove':
await shardState.removeGuild(event.id)
return
// Protocol 4: membership. A roster arrives in one frame for any realistic
// guild and in several for one over the shard's cap — upsertGuildRoster
// handles both. guild.leave is advisory; the next roster would converge
// anyway, but applying it shows the departure at once.
case 'guild.roster':
await shardState.upsertGuildRoster(event)
return
case 'guild.leave':
await shardState.removeGuildMember(event)
return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)