docs(rust): phase 9 as built — protocol 6 and first-party clans as Teams

PLAN.md §24.4-24.7: walked on the Oxide rig end to end and on Carbon
hook by hook; what the rig found (the refusal-first reconcile, the
dropped first feed line and its re-offer, core's 40-character dedupe
key, one module per site). R6 amended by D47. PROTOCOL.md §12 specifies
protocol 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 05:28:16 -05:00
parent de25ed3276
commit eea4f958b2
2 changed files with 208 additions and 4 deletions

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **5** as of configuration from the site (§11) — declared in
The wire version is a single integer — **6** as of first-party clans (§12) — declared in
**four** places that must agree:
| Where | Repo |
@@ -335,8 +335,8 @@ arrives with the phase that needs it, and each is a version bump:
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
- plugin configuration edited from the site (phase 7b)
- clans, for core's Team provider (phase 9)
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
- ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
- leases, budgets and the event actions (phases 12-13)
- the map image over the asset-bridge shape (phase 14)
@@ -1095,3 +1095,104 @@ re-read rather than to guess, and names the reload window that is probably still
running.
---
---
## 12. Protocol 6 — first-party clans
Phase 9, R5. Rust's **own** clan system becomes core's Teams: the plugin reports it, the module
answers core's Team provider from it, and the website owns the clan page. Design of record:
[`PLAN.md`](../modules/rust/PLAN.md) §24 (D47D58).
It is not the uMod **Clans** plugin. That is a separate system that never touches the game's
`ClanManager` (D47), so a server running it has two unrelated clan systems, and only the game's
becomes Teams. The plugin reads nothing of it except whether it is loaded.
**The sidecar changed nothing but its version.** One board and five events, filed by `type` (§8.1).
### 12.1 The `clans` board
A snapshot, re-sent on connect, on the 60-second cadence, and about three seconds after any clan
hook fires, so a roster follows the change that caused it.
```json
{
"kind": "clans", "type": "snapshot", "t": 1790158748054, "serverId": "rust-oxide",
"enabled": true, "backend": "LocalClanBackend", "supported": true, "truncated": false,
"umodClans": false, "count": 1,
"clans": [{
"clanId": 1, "createdMs": 1790158729260, "name": "Northwatch", "color": "#3fa9f5",
"score": 0, "maxMembers": 100,
"members": [{ "steamId": "76561190000000001", "rank": 1, "role": "Leader", "joinedMs": 1790158729264, "name": "…" }]
}]
}
```
| Field | Meaning |
|---|---|
| `enabled` | The game's `clan.enabled` convar. `false` is an authoritative answer — no clans — and is `supported` |
| `supported` / `reason` | Could the plugin read the clans at all. `false` when the backend has not started, or is not the local one (a **Nexus** server keeps its clans elsewhere) — refused with a reason rather than guessed at |
| `truncated` | There may be clans the board does not list. See §12.3 |
| `umodClans` | The uMod Clans plugin is loaded. The website warns that its clans are not Teams |
| `rank` | The member's role rank. **Rank 1 is leader**, and several members may hold it. Absent when the member's role id matched no role — never defaulted |
| `name` | Present when the framework knows the player. Absent, not empty, otherwise |
A member's `LastSeen` is **not sent**. It is presence, and nothing names who is online by default
(PLAN.md §23).
### 12.2 The five events
| `kind` | Hook | Carries |
|---|---|---|
| `clan.created` | `OnClanCreated(LocalClan, ulong)` | clan, founder (`steamId`, `name`) |
| `clan.disbanded` | `OnClanDisbanded(LocalClan, ulong)` | clan, who disbanded it |
| `clan.member.added` | `OnClanMemberAdded(long, ulong)` | clan, the new member |
| `clan.member.left` | `OnClanMemberLeft(LocalClan, ulong)` | clan, the member |
| `clan.member.kicked` | `OnClanMemberKicked(LocalClan, ulong, ulong)` | clan, the member, and who kicked them (`bySteamId`, `byName`) |
"Clan" is always `clanId`, `createdMs` and `clanName`. Every one is **`staff` class** in §8.5's
terms: clan membership is members-only (D49), so the public feed never carries it. It reaches a
clan's members through core's Team feed, where core decides who is a member.
`OnClanColorChanged` is hooked too, but produces no event: a colour is a property of the clan, so it
travels on the board, and the hook only brings the next board forward.
Three facts the hook sites impose, all read from the game's assemblies:
- **The founder's membership fires no `OnClanMemberAdded`.** The game adds them inside the
creation, so `clan.created` implies it.
- **`OnClanMemberAdded` hands over a bare id**, fired from inside the database layer before the
game's cached clan is refreshed. The plugin reads the clan back for `createdMs` and `clanName`; if
even that fails the frame carries `clanId` alone and the website matches on it.
- **The game raises no promote or demote hook.** Leadership travels on the board only, and the
website diffs one board against the next (D54).
### 12.3 Identity, and the ceiling
**A clan's identity is `clanId` AND `createdMs`.** The game keeps clans in `clans.<version>.db` with
the version hard-coded, so a game update that bumps it starts a fresh file whose ids restart at 1.
The website keys a Team on `<serverId>:<clanId>:<createdMs>` (D52), and every clan frame carries
both halves for that reason.
**The game has no "list every clan" call.** Its backend offers get-by-id and get-by-member; the only
listing is the clan leaderboard, which runs `SELECT … ORDER BY score DESC LIMIT ?` with the limit
**clamped to 100**. So the board lists at most the top 100 clans by score. A board at that ceiling
cannot be told apart from one with exactly 100 clans, and says `truncated: true` either way. It also
says so if its rows would pass **768 KiB**, well inside the sidecar's 1 MiB line cap, which drops a
longer line outright — a board that never arrived would read as a server with no clans.
The org lead accepted the ceiling (D55) over reading the game's private SQLite schema directly. A
truncated board is answered to core as partial, so core adds and updates Teams there and never
removes one on its word.
### 12.4 A hook name another plugin also raises
The uMod Clans plugin raises `OnClanDisbanded(string tag, List<ulong> members)`, and a Universal
form with `List<string>`. Both have **the same name and arity** as the game's
`OnClanDisbanded(LocalClan, ulong)`. The bridge declares the game's types exactly, and the framework
matches a call to a method by its argument types, so neither call reaches it.
**Walked on the Oxide rig (2026-09-23):** both uMod-shaped calls were raised from a rig plugin, and
the bridge's own `rg.hooks` count for `OnClanDisbanded` stayed at the one real disband, with nothing
logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the
game's.