feat(shard): ingest guild rosters and departures (protocol 4) #8

Merged
whitlocktech merged 1 commits from feat/teams-phase1-guild-roster into edge 2026-08-17 19:29:07 +00:00
Member

Teams Phase 1, website-module half. Targets edge; edgemain is the v4 cutover.

Spec: docs/link/v4.md · pairs with servuo-plugins #12, link #31

What

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

shard_guild_members holds one row per member per guild, keyed (guild_id, serial). guild.roster replaces a guild's rows, guild.leave removes one, and guild.remove now clears membership too so a disbanded guild leaves no orphans.

Chunking, and why there's no buffer here

A roster over the shard's per-frame cap arrives as several frames. 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, so a redelivery must 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. Acceptable for a projection already only as fresh as a 60s sweep; buffering to close it would duplicate the sidecar's reassembly.

Visibility — the part to review closely

Both kinds are mapped to the existing guilds feature. That mapping is required: 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, on meaning rather than spelling. 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 this specifically, because the difference is a public Guilds page listing character names versus one publishing 150 account names. acct/web_id are still stored — that is what lets a linked member be matched to a site user — just never projected below admin.

Logging

guild.leave is appended to the event log, as the 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 the one fat frame on the wire, so logging it would put a full membership snapshot into shard_events on every membership change.

Notes

  • The PUBLIC_KINDS guard test caught the addition, which is what it's for; its expected set now carries a v4 group alongside the v3 one. Worth a look that the two new public kinds are ones you want public.
  • npm test: 393 passed, 0 failed.
  • Also gates PRs into edge, not just main — ten phases land there, and a nine-PR Android workstream previously landed on an ungated edge with no CI at all.

AI-assisted: written with Claude Code. Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.

Teams **Phase 1**, website-module half. Targets `edge`; `edge` → `main` is the v4 cutover. Spec: [`docs/link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v4.md) · pairs with servuo-plugins #12, link #31 ## What Protocol 2 gave the guild board a member *count* and nothing else — the Guilds page could say a guild had 155 members but never who they were, and `findGuildForActor` answered only for leaders because rank-and-file membership was not in the feed at all. `shard_guild_members` holds one row per member per guild, keyed `(guild_id, serial)`. `guild.roster` replaces a guild's rows, `guild.leave` removes one, and `guild.remove` now clears membership too so a disbanded guild leaves no orphans. ## Chunking, and why there's no buffer here A roster over the shard's per-frame cap arrives as several frames. 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, so a redelivery must 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. Acceptable for a projection already only as fresh as a 60s sweep; buffering to close it would duplicate the sidecar's reassembly. ## Visibility — the part to review closely Both kinds are mapped to the existing `guilds` feature. **That mapping is required**: 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**, on meaning rather than spelling. 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 this specifically, because the difference is a public Guilds page listing character names versus one **publishing 150 account names**. `acct`/`web_id` are still stored — that is what lets a linked member be matched to a site user — just never projected below admin. ## Logging `guild.leave` is appended to the event log, as the 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 the one fat frame on the wire, so logging it would put a full membership snapshot into `shard_events` on every membership change. ## Notes - The **`PUBLIC_KINDS` guard test caught the addition**, which is what it's for; its expected set now carries a v4 group alongside the v3 one. Worth a look that the two new public kinds are ones you want public. - `npm test`: **393 passed, 0 failed.** - Also gates PRs into `edge`, not just `main` — ten phases land there, and a nine-PR Android workstream previously landed on an ungated `edge` with no CI at all. --- AI-assisted: written with Claude Code. Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`.
wtclaude added 1 commit 2026-08-17 18:02:37 +00:00
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
2fa4d87a40
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>
whitlocktech merged commit e93361aa48 into edge 2026-08-17 19:29:07 +00:00
whitlocktech deleted branch feat/teams-phase1-guild-roster 2026-08-17 19:29:08 +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/Module-uo#8
No description provided.