docs(teams): the Teams bet, as built (Teams cutover 6/6) #169

Merged
whitlocktech merged 32 commits from edge into main 2026-08-19 09:02:16 +00:00

32 Commits

Author SHA1 Message Date
508e738527 Merge pull request 'docs(modules): the Rust dry run, revisited for Teams' (#167) from docs/rust-dryrun-teams into edge
Reviewed-on: #167
2026-08-19 08:53:54 +00:00
7e08a55f76 docs(modules): the Rust dry run, revisited for Teams
The kit's README sends a reader here FIRST - it is the shortest honest picture of
the whole job - and it predated Teams, so it taught a second game to build its
teams as private module data and never mentioned the provider. The two places the
contract changed since it was written are now in it, and nothing else moved:
all four findings stand, including the identity gap, which is still the one a
real second module hits first.

What Rust adds that UO does not, and why it was worth revisiting rather than
noting:

- externalId must survive a rename and a Rust team HAS no name - it is a numeric
  team id in the save. The right answer, and the one a designer is least likely
  to reach for.
- `complete` is per SERVER, not per community. Six servers are six team spaces,
  so a provider that can reach five must leave `complete` off or core archives
  every team on the sixth.
- A wipe empties every team, so { ok: true, complete: true, teams: [] } is TRUE
  once a month and core archiving all of them is correct - which is exactly why
  an unreachable RCON must answer { ok: false } instead. The two states are one
  API call apart and only the module can tell them apart.
- The team route carries a server id as well as a team id, so the external id is
  <serverId>:<teamId>. Core stores that and never parses it; an external id is
  opaque to core by design, and this is the case that shows why.

Also: rust_teams stays the module's table and core's teams stays core's, which is
the boundary worth stating in the one document where both appear; and the kit is
nine members now, not seven.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 03:51:07 -05:00
987c0b02d2 Merge pull request 'docs(teams): phase 11 as built, and what the rig walk found' (#166) from docs/teams-phase11-record into edge
Reviewed-on: #166
2026-08-19 06:37:24 +00:00
2adf397da4 docs(teams): phase 11 as built, and what the rig walk found
The phase ran before the cutover as Part 12 planned, and found what it was meant
to: the inverted slot direction worked for module-uo and nobody else. Recorded
beside the phase entry, with the org lead's two decisions of the day - core
offers a contribution rather than naming a slot, and the template grows a real
provider rather than a snippet.

Also records the live-rig walk and the one defect it found that no test could:
PageHeader takes `lead`, not `subtitle`, and React drops an unknown prop in
silence, so every page built from the kit's template had been rendering its
heading with nothing under it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:31:52 -05:00
f1fb2d40b4 Merge pull request 'docs(modules): core offers a contribution, never a slot name' (#165) from docs/teams-slot-contributions into edge
Reviewed-on: #165
2026-08-19 06:18:55 +00:00
d8af33c805 Merge pull request 'docs(teams): cancel phase 10, and say what that leaves behind' (#164) from docs/teams-phase10-deferred into edge
Reviewed-on: #164
2026-08-19 06:18:10 +00:00
d10f5809b3 docs(modules): core offers a contribution, never a slot name
Amends MODULE_API 1.6.0 in place - it has only ever been on edge, the same rule
the eighth and ninth members were given - and it is a correction rather than an
addition.

As first written, the inverted slot direction had core fill three literal
uo.guild.* names. That worked for module-uo and silently did nothing for anyone
else: a module declaring clan.detail under its own id got an empty page and no
error, because "a fill for a slot nobody declared is not an error" is exactly
the rule that makes an unknown name invisible. It also put a module identifier
inside core, in string literals the Sec 5.2 checker masks by construction.

Sec 3.7a now documents declareModuleSlot(id, name, { core }) and the three
contributions core offers - team.activity, team.forum, team.notify - as a
table, with the rules that follow from the direction: the member is optional, a
slot that asks for nothing stays empty, more than one slot may ask for the same
contribution, and asking for one core does not offer THROWS at the declaration
rather than rendering empty forever.

TEAMS.md's two accounts of the inversion (Part 3's supersession note and the
phase 3 amendment) say the same thing.

Also corrects the UI kit's count in Sec 3.4 and Sec 3.7a: Slot made it nine in
phase 3 and three places still said eight.

Found by phase 11 while writing the chapter that teaches this shape to an
audience outside this org.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:16:07 -05:00
88cc49225a docs(teams): cancel phase 10, and say what that leaves behind
The org lead cancelled the capability layer on 2026-08-19, deferring it until a
second integration is wanted or it is asked for by name. Phase 11 is now the last
phase of the bet.

The same argument that put phase 10 last is the argument for not doing it yet:
with one integration built, the refactor would extract a capability surface from a
single implementation and have nothing to check the extraction against. It is
cheaper and better-informed the day a second platform exists, because that
platform is what proves which of the five capabilities the seam needs.

Three places pointed forward at it and now say what is true instead:

- Sec 7.2 and Sec 7.3 both justify the Admin -> Teams panels by "phase 10 makes
  the platform a registry lookup". The decision survives its reason: an operator
  should not have to know which platform is configured to find the panel, and that
  holds whether or not the registry is ever built.
- Sec 8.2 keeps the Matrix comparison and the capability table, with a note that
  no registry is built either. The research did its job by keeping core's calls
  phrased as eligibility questions rather than as Discord operations; what is
  absent is the indirection, so `discord` is named directly in the bridge, the
  voice provisioner and the command dispatcher.

The phase entry keeps its body rather than deleting it, because the argument for
the layer is what a future phase would start from.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:27:22 -05:00
6f1f406fe1 Merge pull request 'docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist' (#163) from docs/teams-phase9-voice into edge
Reviewed-on: #163
2026-08-19 05:15:43 +00:00
c87034d7fe docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist
Amends `TEAMS.md` §7.3 inline, marks phase 9 done in Part 12, and adds the
`team_integrations` row to `BACKEND_DESIGN.md`'s schema table. Pairs with
**website#159**.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:10:18 -05:00
d00ead09b6 Merge pull request 'docs(teams): phase 8 as built — the gate §7.2 could not check, and the key it could not hold' (#162) from feature/teams-phase8-notifications-bridge into edge
Reviewed-on: #162
2026-08-19 01:32:22 +00:00
71f0b7ad90 docs(teams): phase 8 as built — the gate §7.2 could not check, and the key it could not hold
Amends §7.2 inline and marks phase 8 done in Part 12; adds the
team_integration_config row to BACKEND_DESIGN.md's schema table.

Two of the amendments are things the tree disproved rather than choices:

- §7.2's DDL cannot hold its own default row. MariaDB coerces PRIMARY KEY
  columns to NOT NULL, so `team_id NULL` is unrepresentable and the override
  mechanism has no base case. Confirmed against a real MariaDB (error 1048).
- §7.2's visibility gate has no data source on either side and cannot have one:
  the streams carry no visibility, a forum thread is members-only by
  construction rather than by a column, and core cannot see a channel's
  permissions. The gate becomes an attributed operator acknowledgement.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:25:41 -05:00
f5c121b02a Merge pull request 'docs(teams): phase 7 as built — five amendments to §7.1' (#161) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #161
2026-08-19 00:15:15 +00:00
953f7fcd20 docs(teams): what the phase 7 rig walk proved, and the two defects it found
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:37 -05:00
c196d03d31 docs(teams): phase 7 as built — five amendments to §7.1
The command that proves the seam is the MODULE's `/guild`, not core's `/team`:
§7.1 was written before phase 3 settled that Teams is a contract primitive with
no core surface, and a core `/team` publishes the same invented noun that got
core's Team pages deleted. Its deep link comes from `pageUrlTemplate` for the
same reason — `/teams/:slug` does not exist.

The re-register nudge is its own bot endpoint rather than a ride on
`/internal/config`, whose body carries the decrypted bot token. `actor` carries
`role` beside `isStaff`, since a module with its own audience rungs cannot place
a caller from a boolean. And "deregistration is free" needed a second half: it
holds across the restart an uninstall asks for, not across the runtime toggle,
so liveness is asked at both the pull and the dispatch.

MODULE_API.md stops saying `registerSlashCommands` throws and documents it —
every member of 1.6.0 is live now.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:56 -05:00
30d964c090 Merge pull request 'docs(teams): queue the integration kit as phase 11, last before the cutover' (#160) from docs/teams-phase11-queue into edge
Reviewed-on: #160
2026-08-18 23:19:26 +00:00
9f90a99362 docs(teams): queue the integration kit as phase 11, last before the cutover
The kit is the instruction book for putting a different game on this platform,
written for an audience outside this org. Teams expands the contract that book
teaches against, so the book is the last thing the bet owes before `edge` becomes
`main` (org lead, 2026-08-18).

One sentence in it is already wrong rather than merely incomplete.
`book/02-website-module.md` tells a reader that core declares a slot and a module
may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares
three — a new game's module cannot implement Teams at all without the inverted
direction.

Two shapes are genuinely new and worth teaching: the inverted slot, and
`registerTeamProvider` as the first registration where core calls the module and
waits — with the asymmetry that every call fails stale except `projectRoster`,
which fails closed, because for a visibility question "keep what you have" means
serving the roster unprojected.

The phase explicitly does NOT enumerate the contract. The kit already teaches four
members and has never mentioned notification streams, announce legs or post hooks,
all of which predate Teams. MODULE_API.md is normative; the kit teaches one path
and links out.

Its ordering is awkward and is stated rather than smoothed over: it is written
before the cutover and can only merge after it, because CI clones the pinned sha
and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does
not reach `main` until the cutover lands.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:18:26 -05:00
703f0dbe67 Merge pull request 'docs(teams): phase 6 as built — four deviations and a ninth contract member' (#159) from docs/teams-phase6 into edge
Reviewed-on: #159
2026-08-18 23:09:39 +00:00
5e284d5468 docs(teams): phase 6 as built — four deviations and a ninth contract member
Part 6 gains an as-built header rather than a rewrite, so the reasoning that
produced the original design stays legible beside what the build learned.

Four deviations. There was no web notification settings screen to add the Team
list to — `/auth/me/notifications/*` was built for the app in M7 and had zero web
consumers, which is survivable for push and not for a sink whose whole argument is
the web-only user. Email defaults to `off` rather than `digest`, on the org lead's
call: digest-by-default would start mailing every member of every Team the moment
an operator connects Gmail. Roster events tickle but do not email. And a ninth
member joined MODULE_API 1.6.0.

`pageUrlTemplate` is the member, and it exists because phase 3 left core with no
Team page and therefore no way to link to one. It joins 1.6.0 in place under the
rule set in phase 2 — a contract owes a bump only once it has landed on `main`,
and 1.6.0 has only ever been on `edge`.

Two further build decisions are recorded where they belong: the digest computes at
send time and keeps no queue (§6.4), and one-click unsubscribe is a stateless HMAC
whose whole capability is muting one (user, Team) pair (§6.4).

BACKEND_DESIGN gains the table, the two `/auth/me` routes and the unsubscribe
endpoint — the only write in the public tier and the only route with no `siteMode`,
because the mail went out before the site went into maintenance.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:49 -05:00
04eb859a48 Merge pull request 'docs(teams): phase 5 — discussion, the edit window, and reports that route around leadership' (#158) from docs/teams-phase5 into edge
Reviewed-on: #158
2026-08-18 18:37:19 +00:00
aed4ec4166 docs(teams): phase 5 — discussion, the edit window, and reports that route around leadership
TEAMS.md §5.4, §5.5.7 (new), §5.6 and the Part 12 phase entry; BACKEND_DESIGN.md's
schema and admin route tables.

**The largest change is a decision, not a description.** §5.6's first rule said
"a leader may also see and act on reports for their own Team, but staff always
receive them". The org lead settled on 2026-08-18 that the leader half is
**decided against, not deferred**: the gap the section exists to close is that
leaders moderate their own forum and a Team's leaders are exactly the people who
will not report their own Team, so a leader-visible queue hands a complaint about
a leader back to them — and a read-only leader view still tells them who reported
what. Recorded as an amendment rather than by editing the sentence away, because
the reasoning for the original is what makes the correction legible.

**§5.6's `content_reports` DDL does not work as written, and the amendment says so
rather than quietly swapping it.** With `status` in the unique key, CLOSED rows
collide with each other: dismiss a report, let the behaviour recur, dismiss the
second one, and the UPDATE lands on a tuple that already exists — so the queue
starts throwing duplicate-key errors on the first repeat reporter. The shipped
table keys on a generated `open_marker`, the same encoding
`team_forum_grants.active_marker` uses. Three smaller departures are recorded
beside it: `handled_note`, the two username snapshots §2.10 asks for everywhere
else, and a real CASCADE on `team_id`.

**New §5.5.7 for `teams_forum_edit_window_minutes`** (0–1440, default 15), and the
rule under it: the window is resolved on the server TWICE — the read path stamps
`canEdit`/`editableUntil` so a client knows whether to draw the control, the write
re-derives it from `created_at` before allowing anything. The read is advice and
the write is enforcement, because a time-bounded permission must not take its
clock from the party it bounds. That is also why the key is not published: the
client needing the number is the admin screen, and the client needing the decision
already has it per post.

**§5.4 gains three notes its route table does not carry**: thread creation splits
authority by TYPE rather than widening the leader gate (and reports it as two
booleans, since one would make a client guess which right it described); post
moderation is its own route whose validator accepts all eight actions so the model
can say "pin applies to a thread, not to a post"; and a reply's three refusal codes
are chosen to be distinguishable — 404 absent, 400 announcement, 409 locked — with
locked refusing staff too.

The Part 12 entry records what the phase disproved, its four acceptance criteria,
that it spans ONE repo where phase 4 needed two, and the single defect the live rig
found. It also notes that phase 4 shipped `uploads` with the default off, so §5.6's
"pull reports forward if uploads is enabled anywhere" never triggered.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:29:56 -05:00
36dbe9c985 Merge pull request 'docs(teams): phase 4 — the forum's access model, switches and image policy' (#157) from docs/teams-phase4 into edge
Reviewed-on: #157
2026-08-18 14:18:22 +00:00
0e665078e8 docs(teams): phase 4 — the forum's access model, switches and image policy
Records what building phase 4 settled, and what it disproved.

The structural correction first: TEAMS.md 3.1 gave the forum a CORE page and phase
3 deleted every core Team page. The ROUTES were unaffected — they are all /player
and /admin — but the participant surface had no home, and 5.4's route table did not
notice. Settled the way phase 3 settled the activity feed: module-uo declares a
second place on its guild page and core fills it, so the phase spans two repos
rather than the one the plan named.

Two slots rather than one, because a slot holds one component and the first fill
wins; the panel navigates by search param because a thread must be linkable and
core cannot mount a route on a page it does not own.

Two findings from the sanitiser worth not re-deriving: `rel` has to be on the
allowlist for the transform that WRITES it to survive, or every forum link ships
without noopener; and the bare-URL linkifier runs after sanitising, over escaped
text only, which is the property that makes it safe rather than an injection point.

Also recorded: the upload sweep runs regardless of the current image mode, which is
the mechanism behind the dialog's promise that disabling uploads does not delete
what is already there; the two routes the table lacked; and the org lead's decision
that all three proposed acknowledgement additions ship.

BACKEND_DESIGN gains the four forum tables and the reasoning a reader of the schema
alone would miss — why the guard is at the route and never at the data, why no
stored body ever contains an <img>, what `uploads` mode hardens, and what the
acknowledgement actually records. MODULE_API's inverted-slot section gains the rule
a module needs: one slot per PLACE, not one per page.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:25:05 -05:00
7121b1a028 Merge pull request 'docs(teams): phase 3 — Teams is a contract, not a surface' (#156) from docs/teams-phase3 into edge
Reviewed-on: #156
2026-08-18 02:09:32 +00:00
d78cc99c80 docs(teams): Teams is a contract, not a surface — record the correction
The org lead's correction to Part 3, and the inverted extension-slot direction
it forces.

TEAMS.md: §3.1's routes, §3.4's two slots and §3.5's three nav entries are all
marked superseded in place, and Part 12's phase 3 entry gains the amendment
explaining why — core does not own the word for a Team, so the module that owns
the vocabulary owns the page. The five corrections found by building are kept
alongside it.

MODULE_API.md: 1.6.0's list swaps the two client slots for
registry.declareModuleSlot + Slot in the UI kit, and a new §3.7a documents the
inverted direction: what forced it, the enforced namespace, why core's fills are
applied at mount rather than eagerly, and why a fill for an undeclared slot is a
no-op where §3.7's unknown slot throws.

BACKEND_DESIGN.md: the by-external-id lookup route.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:58:40 -05:00
fd9c02130c docs(teams): Team pages and the activity feed, and what phase 3 disproved
TEAMS.md gains a dated amendment on phase 3 with five corrections, all found by
building the thing it describes:

  - §3.2 and §3.4 contradict each other about `team.member.row`'s props, and
    §3.2 wins because it is the security rule. A client slot can only receive
    what the browser was sent, so §3.4's `{ memberKey, userId, displayName }`
    means publishing both identifiers in every public roster, module installed
    or not. The slot is redeclared with what core can honestly supply.
  - §3.3's projection is an EIGHTH MODULE_API member where 1.6.0 listed seven.
    Settled by the org lead: 1.6.0 is amended in place, on the rule Protocol 4
    was given in phase 2 — a contract owes a bump only once it has reached
    `main`.
  - "the module declines" needed splitting in two before it could be built. No
    module at all withholds nothing and must serve the roster whole; a module
    whose rungs could not be consulted must serve none of it. Only the second
    fails closed, or bare core shows an empty roster on every Team page.
  - the module answers with member KEYS, not rows, so it can narrow what is
    published and cannot widen it.
  - core's five activity kinds are four until the forum lands, and a Team's
    FIRST roster emits no join items at all.

§2.11's route table gains the activity endpoint it never had, and MODULE_API.md
documents `projectRoster`, the inverted fail-closed semantics that make it
different from every other provider call, and `ctx.teams.activity.push`'s item
shape and its four contractual properties.

BACKEND_DESIGN.md: the seventh Team table, its retention, and the three public
routes' new behaviour — `enabled` on the index, the slot props on the single
Team, the per-caller row projection on the roster, and the feed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:17:05 -05:00
cbc492ea52 Merge pull request 'docs(link): roster members carry guild rank' (#155) from docs/protocol4-guild-rank into edge
Reviewed-on: #155
2026-08-17 22:48:31 +00:00
0622ed00e0 docs(link): roster members carry guild rank
Amends Protocol 4 in place rather than bumping it: the protocol has not reached
`main`, and a bump is owed only once a protocol has been released.

The roster shipped as the standard actor object, which carries no rank. Teams
phase 2 found the consequence -- the website could learn leadership only from the
board's single `leader` field, so it could name exactly one leader while a UO
guild routinely has several, and TEAMS.md §2.5 treats multiple leaders as the
normal case.

Roster members now carry `rank` (0-4, 4 being Leader) plus `rankCliloc`, or
`rankName` where a shard's custom rank definitions use literal names. Rank is on
roster members only -- it is a property of a mobile's membership of THIS guild,
not of the mobile, and every other actor the bridge writes is a bystander, a
killer or a governor.

Both files carry the trap this found, because it is the kind of thing a consumer
gets wrong silently: **`PlayerMobile.GuildRank` returns Leader for anyone at
GameMaster or above, whatever their real rank.** It is a gameplay convenience so
staff can operate a guild stone, and the true value has no accessor -- so the
bridge omits the rank entirely for a staff account rather than publish a
leadership claim it knows is false. An absent rank therefore means "not known",
and a consumer must read it as neither 0 (which silently demotes them) nor
leadership (which republishes the lie).

INTEGRATION.md also states the other half plainly for an outside integrator:
`guild.update`'s single `leader` is the founder-leader, not the set of leaders,
so "who leads this guild" is a read of the roster's ranks.

Recorded too: the sidecar needs no change and no store migration, because it
treats roster members as opaque values and never reads a field inside one. That
is the forwarder design paying off, and it is worth having written down the next
time someone adds a member field.

Pairs with servuo-plugins (the emitter) and Module-uo (the ingest and the
provider).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:44:04 -05:00
e68affbcf5 Merge pull request 'docs(teams): Team core, MODULE_API 1.6.0, and what phase 2 disproved' (#154) from feat/teams-phase2-team-core into edge
Reviewed-on: #154
2026-08-17 22:19:17 +00:00
680a4866ac docs(teams): Team core, MODULE_API 1.6.0, and what phase 2 disproved
Documents phase 2 of docs/website/TEAMS.md across the three files that had to
change, and records the five places building it disagreed with the design.

## MODULE_API.md — 1.6.0

The Team surface becomes contract: `api.registerTeamProvider(...)`,
`ctx.teams.publish` / `ctx.teams.reconcile` / `ctx.teams.activity.push`,
`api.registerSlashCommands(...)`, and the two client slots. Additions only, so
minor; module-uo's `coreApi: "^1.3.0"` still resolves.

Per the org lead's decision, one 1.6.0 covers the whole surface rather than a
minor per phase -- so the document names the phase against each member, and the
two that cannot work yet are marked as present-and-throwing rather than left to
be discovered at runtime.

`registerTeamProvider` gets the fullest treatment because it is the first
registration where core calls the MODULE and waits for an answer. The envelope,
the 10-second budget and the refusal semantics are all contract, not
implementation: they are how a module says "I cannot answer" without core hearing
"there is nothing". `ctx.teams` is documented as push-only, with the reason there
is no reader — a module answers questions about Teams, it does not ask them.

## BACKEND_DESIGN.md

The six Team tables, the rename rule, the active-only uniqueness encoding, the
per-column account-deletion decisions, and all eighteen routes across the three
tier tables.

Two entries there exist to stop a future reader "fixing" them: why
`team_forum_grants` does not use the obvious generated column, and why the two
columns TEAMS.md never mentioned have to exist.

## TEAMS.md — five amendments, marked as amendments with their date

  - **§2.5's SQL and §2.10's decision cannot both hold.** MariaDB refuses ON
    DELETE SET NULL on a base column of a stored generated column (1901), so
    §2.5's `active_user` forces the CASCADE that §2.10 exists to prevent. §2.10
    wins; the marker is re-encoded for identical semantics.

  - **`team_forum_grants` lands in phase 2**, so the four-path resolver is written
    once and its non-contamination tests are real.

  - **Two columns the document did not contemplate**, both serving §2.4's gates:
    `roster_synced_at`, because sync state is per MODULE and gate 3 leaves one
    Team behind while the others sync; and `members_empty_since`, gate 4's
    per-Team quarantine.

  - **`leader` on the member shape is not path 2.** Taking §2.3 and §2.5 both
    literally gives one column two writers, and the roster writes first — so a
    refused `getTeamLeaders()` silently demoted everyone. Found by its own test.

  - **§2.8.2's matcher needed two narrow widenings**, both real impersonation
    vectors the whole-word rule missed: a term matches a name word's singular
    ("Guild of Moderators"), and a run of single-letter words is compared joined
    ("G.M."). Neither re-admits substring matching.

Pairs with website (Teams phase 2) and Module-uo (the provider).

Refs docs/website/TEAMS.md Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:32:21 -05:00
11696d14e3 Merge pull request 'docs(link): protocol 4 — guild membership on the wire' (#153) from feat/teams-phase1-guild-roster into edge
Reviewed-on: #153
2026-08-17 19:28:04 +00:00
b369e728c2 docs(link): protocol 4 — guild membership on the wire
Adds v4.md as the spec of record for `guild.roster` and `guild.leave`, and
corrects the two older documents that Protocol 4 makes wrong.

PROTOCOL_2.md §10.1 already described this design — hold a member-serial set,
diff it each sweep, emit join/leave — and 2.0 then shipped only the half needing
no new state, folding membership into the board signature as a serial *sum*. The
section has read ever since as though the whole thing were built. It now says
which half shipped, and carries the correction that doing it produced: a sum is
not a safe stand-in for a set, because one member joining and another leaving
between two sweeps offset each other and the guild reads as unchanged.

INTEGRATION.md gains both kinds in the event catalogue, the `roster` key on
GET /guilds, and the three things an integrator gets wrong otherwise — that
`guild.leave`'s `who` is a bare serial rather than an actor object (the mobile has
already left, so there is nothing to attribute), that `acct` is genuinely optional
on a member, and that a guild with no `roster` key is not the same as one with an
empty roster.

v4.md documents what the phase found as well as what it built: the missing store
migration and the user_version decision, why the roster lives in its own column
rather than inside the guild.update snapshot, why a split roster is reassembled in
memory rather than appended to the column, and why guild.leave gets no board
projection at all. §6 records that the reassembly bug was invisible to every unit
test — they all exercised single-frame rosters — and only the live rig caught it.

TEAMS.md is amended where this phase disagreed with it: Phase 1 spans five repos,
not four, because installer/backup.rs justifies skipping the sidecar database on
reasoning the migration falsifies. The user_version decision is recorded there too,
since the design of record did not contemplate a migration mechanism at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 13:01:05 -05:00