docs(rust): phase 9 — Teams from first-party clans (PLAN.md §24, PROTOCOL.md §12) #264

Merged
whitlocktech merged 2 commits from docs/rust-phase-9 into main 2026-09-23 10:31:37 +00:00
2 changed files with 304 additions and 4 deletions

View File

@@ -221,6 +221,10 @@ upstream pull request adding a read method, not a file reader.
### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager ### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager
> **Amended 2026-09-23 by D47 (§24):** the uMod **Clans** plugin is no longer required. It moved to
> phase 17's optional tier, because it shares no data with the game's own clans, which are what
> Teams are built from. The required set is Kits, PopupNotifications and ZoneManager.
**Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's **Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's
and BetterChat's author differs only in the optional tier. All fetched at plan time: and BetterChat's author differs only in the optional tier. All fetched at plan time:
@@ -987,7 +991,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one | | 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
| 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin | | 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app | | 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app |
| 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly | | 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.***Built 2026-09-23 — plan, as built and findings in §24** (D47D58). Walked on the Oxide rig end to end and on Carbon hook by hook; the game's 100-clan ceiling accepted (D55); **one module per site** became a core rule on the way (§24.5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else | | 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there | | 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking | | 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
@@ -3407,6 +3411,201 @@ own Integration-kit PR alongside this one.
--- ---
## 24. Phase 9 — Teams from first-party clans (plan of record, 2026-09-23)
R5's phase. Rust's **own** clan system becomes core's Team provider: a clan is a Team, its page is
this module's, and core's three contributions (the notification control, the activity feed and the
forum) land in places the module names. It was preceded by the presence fix (§23), which this
phase's roster question produced, and it builds on that fix's settings table.
Three repositories and this document: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr],
all into `edge`. Android is not in this phase; R10's legs trail by one, and the Team surface reaches
the app with core's own Team screens rather than a Rust-specific one.
### 24.0 The decisions this phase needed
| # | Decision |
|---|---|
| **D47** | **The uMod Clans plugin becomes OPTIONAL.** It moves out of `overlay.toml`'s `requires_plugins` and into phase 17's optional tier, which **amends R6**. The two systems share no data (the plugin never touches `ClanManager`), so a server running both splits its players across two unrelated clan systems. First-party clans are the only Teams source, and the admin surface warns when the plugin is loaded that its clans are not Teams. |
| **D48** | **A clan roster is shown to clan members and staff by default.** One fleet-wide operator setting with three rungs: `members` (the clan's own linked members, plus staff) · `signed_in` · `public`. It is §23's rule applied to a roster: a roster says who is in a clan and, inside its audience, who of them is on. **Widening it warns that it publishes online status too**, because core's `projectRoster` can withhold rows but not fields. |
| **D49** | **The clan activity feed carries membership only (founded, joined, left, removed), and every item is members-only.** Written with `visibility: 'members'` on every item. |
| **D50** | **Clan score and colour are in.** Score sorts the Clans tab and colour is shown as a swatch. **Deferred:** clan logos (images over the bridge into `ctx.uploads` is asset-bridge-shaped work that would roughly double the phase) and a Discord `/clan` command (stays in phase 16). |
| **D51** | **Protocol 6 is one board and five events.** A `clans` snapshot, re-sent on connect and on the 60-second cadence, plus `clan.created`, `clan.disbanded`, `clan.member.added`, `clan.member.left` and `clan.member.kicked`. The sidecar files by `type` (§8.1), so it only bumps its version. |
| **D52** | **`externalId` is `<serverId>:<clanId>:<createdMs>`.** The clan id alone is not an identity: the game's clan database is `clans.<version>.db` with the version hard-coded, so a Facepunch bump starts a fresh file whose ids restart at 1. Without the creation time, the new clan #1 would inherit the old clan #1's Team, its forum and its members-only history, and core would read the swap as a rename. |
| **D53** | **`getTeams` is `complete: true` only when every server's board is fresh**, supported and untruncated. Otherwise it answers `complete: false`, which core reads as "add and update, remove nothing". One server being offline must never archive its clans. When no server's board is fresh it refuses. |
| **D54** | **Leadership comes from the board, diffed one snapshot against the next**, and is published as `team.leader.added` / `team.leader.removed`. The game raises no promote or demote hook, so this is correct to within one cadence (about a minute). A leader is any member whose role has rank 1, and there may be several. The snapshot is shaped so phase 17 can sharpen it with the uMod plugin's `OnClanUpdate` rather than replace it. |
| **D55** | **The board is bounded, and its ceiling is the game's.** The game offers no "list every clan" call; the only listing is the clan leaderboard, which **the game clamps to 100 clans by score**. The org lead accepted that ceiling (2026-09-23) over reading the game's private SQLite schema directly. A server at 100 clans or more, or whose board would not fit on one line of the game link, reports `truncated: true` and the answer becomes partial: Teams there are added and updated but never removed. A **non-local clan backend** (Nexus) is refused with a reason rather than guessed at. |
| **D56** | **The pages are `/rust/clans/:externalId`, with a Clans tab on each server's page.** `pageUrlTemplate` substitutes only `{externalId}`, which rules out a route nested under the server. Three module slots are declared: `rust.clan.header` (core's `team.notify`), `rust.clan.detail` (`team.activity`) and `rust.clan.forum` (`team.forum`), one per place, as `module-uo` does. |
| **D57** | **Linking or unlinking an account calls `ctx.teams.reconcile`**, so a member's `userId` appears straight away instead of on core's next sweep. |
| **D58** | **The clan LIST is public; the roster is not.** Each clan's name, colour, score and member count names no player, so the Clans tab shows them to anyone (org lead, 2026-09-23). Rosters, leaders and who is online stay behind D48's audience. |
### 24.1 Facts the phase rests on
Read from the game's own assemblies (`Rust.Clans.Local.dll`, `Rust.Clans.dll` and `ClanManager`
in `Assembly-CSharp.dll`, decompiled off the Oxide rig), not from a catalogue:
- **`clan.enabled` defaults to `true`**, so every server has first-party clans unless an operator
turns them off. `ClanManager.ServerInstance.Backend` is null until the backend initialises.
- **The backend API has no enumerate-all call.** `IClanBackend` offers `Get`, `TryGet`,
`GetByMember`, `Create`, `ListInvitations` and `GetLeaderboard`. `GetLeaderboard(limit)` runs
`ListTopClans`, which is `SELECT clan_id, name, score FROM clans WHERE deleted IS NULL ORDER BY
score DESC LIMIT ?` with the limit clamped to **10..100**. That clamp is D55's ceiling.
- **The local backend is synchronous under an async signature.** Every `ValueTask` it returns has
already completed, so the plugin can read results on the main thread without blocking. A
backend whose task has not completed is treated as unsupported rather than waited on.
- **A leader is any member whose role has `Rank == 1`, and several are allowed.** The game only
stops you removing the last one. **Clans cannot be renamed**: `IClan` has no `SetName`.
- **Hook sites.** `OnClanCreated(LocalClan, ulong)` fires in `LocalClanBackend.Create` after the
commit. `OnClanMemberAdded(long clanId, ulong steamId)` fires inside
`LocalClanDatabase.AcceptInvite` with a **bare id**, before the cached `LocalClan` has been
refreshed. `OnClanMemberLeft`, `OnClanMemberKicked` and `OnClanDisbanded` fire from `LocalClan`.
**The founder's own membership fires no `OnClanMemberAdded`**, so `clan.created` implies it.
- **The uMod Clans plugin raises a hook with the SAME name.** It calls `OnClanDisbanded(string tag,
List<ulong> members)`, the same name and arity as the game's `OnClanDisbanded(LocalClan, ulong)`.
The bridge declares the game's types precisely, so a call with the plugin's types does not match
it. This is checked on the rig, which runs the plugin.
### 24.2 What the plan builds
**The plugin (protocol 6).** The `clans` board carries, per clan: `clanId`, `createdMs`, `name`,
`color` (`#rrggbb`), `score`, `maxMembers`, and `members` (each `steamId`, `rank`, `role`,
`joinedMs`, and `name` when the framework knows one). It also carries the board-level facts D53
and D55 need: `enabled`, `backend`, `supported`, `truncated`, and `umodClans`, which is whether the
optional plugin is loaded (D47's warning). A member's `LastSeen` is deliberately **not** sent: it
is presence (§23). The five events carry `clanId`, `createdMs`, the clan's `name` and the Steam
ids involved. A membership event also schedules a board re-send a couple of seconds later, so the
roster that follows it does not wait a minute.
**The sidecar.** `PROTOCOL_VERSION` becomes 6. Nothing else changes.
**The module.**
- **The store:** `rust_clans` and `rust_clan_members`, replaced per server from each board, plus
`rust_clan_boards`, which records per server when the board's `t` last advanced by the
**website's** clock. Freshness is judged by that, so clock skew between the game host and the
website cannot make a stale board look fresh.
- **The provider:** `getTeams`, `getTeamMembers`, `getTeamLeaders` and `projectRoster` (D48), plus
`pageUrlTemplate: '/rust/clans/{externalId}'`. `userId` comes from the module's own link table,
`online` from its own presence board, and `rankLabel` from the clan's role name.
- **The push:** each `clan.*` event is published to core (`team.created`, `team.disbanded`,
`team.member.added`, `team.member.removed`) and written to the Team feed as a members-only item
(D49), with a `dedupeKey` from the event's own id so a replay is a no-op. A board that changes
the set of clans asks core to reconcile, and a board that changes a leader publishes it (D54).
- **The pages:** the Clans tab (public list, D58) and `/rust/clans/:externalId`, whose roster is
projected by the same function core calls. The roster setting is a second section on the
existing **Rust visibility** admin page, which also lists any server running the uMod plugin.
- **The feed:** the five kinds are `staff` class in the public feed's allowlist. They reach the
public only through core's members-only Team feed.
**The docs.** This section as built, `rust-link/PROTOCOL.md` §12 (protocol 6), the R6 amendment
and `overlay.toml`'s requirement list.
### 24.3 Done when
The phase row's criterion: *the clan page is ours, core's contributions land in places we named,
and every slot empty still reads correctly.* Walked against the Oxide rig with real clans, plus a
Carbon load of the same plugin file.
### 24.4 As built, 2026-09-23
Rust-Plugins, Rust-Link and Module-Rust on `feat/phase-9-clans` (into `edge`), and this document.
**Core needed one change, and it is not a Teams change:** the org lead's rule that a site runs one
module (below), shipped as its own website PR with its own docs PR.
**The criterion is met on a live rig.** Against the Oxide rig with the protocol-6 sidecar inside the
container, and a core at the pinned sha running only this module, a rig-only helper plugin drove
the game's own clan backend with synthetic Steam ids:
- Two clans founded, two members added, one promoted, one leaving, one kicked, one disbanded.
Every hook fired (`rg.hooks`: created 2, added 3, left 1, kicked 1, colour 1, disbanded 1).
- Core created a Team per clan, synced the rosters with the right leaders, marked the leaver and
the kicked member departed, published the promotion from the board diff, and **archived the
disbanded clan's Team** on the next complete answer.
- The clan page renders signed out with the roster withheld and a sentence saying who may see it.
Core's activity contribution is in `rust.clan.detail`; the notify and forum slots are empty for a
visitor, and the page reads correctly without them.
- The roster gate agrees on both surfaces, the module's page and core's
`/public/teams/:slug/members`: hidden from an anonymous visitor and from a signed-in player
outside the clan, shown to that player once their link is to a member, and shown to a
moderator. Neither leaks a Steam id or an account id.
- Admin: a moderator's `PUT` is 403, an unknown rung is 400, and `public` opens the roster to an
anonymous visitor.
**On Carbon** (the other rig, Carbon 2.x): the byte-identical plugin compiled and loaded, and the
same helper fired every clan hook with the right counts.
### 24.5 What the rig found that the plan did not say
**1. Core's first answer is a refusal, and that is correct.** The `clan.created` event reached core
through the ingest cursor (every 5 s) before the first `clans` board reached it through the refresh
(every 30 s). Core's reconcile asked the provider and was **refused**: no current board. Twenty
seconds later the board arrived and the Teams were created. The alternative, answering from events
alone, is exactly what the envelope contract forbids.
**2. A Team's first feed line would always have been dropped.** Core writes an activity item only
for a Team it already holds, and a new clan's Team does not exist until core's next reconcile,
debounced by up to 30 seconds. "Founded the clan", the first line of every clan's feed, was
dropped on the live run (`teams activity push: dropped items`), as predicted. The module now
re-offers the last ten minutes of clan events on every board refresh. On the rig, all four
dropped lines landed on the next refresh, each once.
**3. Core clamps a `dedupeKey` to 40 characters, silently.** `MAX_DEDUPE = 40` in core's
activity model. A readable key long enough to be unique (server, clan, creation time, kind, player,
instant) is 80 or more characters, so it would be cut short into collisions, and a
re-offer would then have deduplicated *different* events into one. The key is a sha1, exactly 40.
`MODULE_API.md` does not state the limit; a kit reader would find it the same way.
**4. `occurredAt` is epoch milliseconds, not a `Date`.** Core reads `Number.isFinite(item.occurredAt)`
and replaces anything else with now. A `Date` would have stamped every re-offered line with the time
of the re-offer.
**5. Game ids restart with the database version, as §24.1 said, and the rig showed ids from 1.** The
fresh rig numbered its first clan `1`, which is why `createdMs` is in the identity.
**6. The ceiling is the game's, and it is 100.** `ListTopClans` clamps its limit to 10..100 (D55).
**7. One Team provider per site, and a load order that made it worse.** Core holds a single provider
and a second registration fails that module's **whole load**. Modules load alphabetically, so
installing this module beside `module-uo` would have taken `uo` down, not `rust`. The org lead's
rule, 2026-09-23: **a site runs one module.** Core's installer now refuses a module whose id
differs from the installed one (409) and accepts only upgrades (`MODULE_SYSTEM.md` §2.5, decision
35). This is its own website PR, not part of this phase.
**8. The uMod plugin's same-named hook does not reach the bridge, on either framework.** Raised in
both of the plugin's shapes, the bridge's own count for `OnClanDisbanded` stayed at the one real
disband, with nothing logged (PROTOCOL.md §12.4).
**9. A clan event must not move `last_seen`.** The ingest's `touchPlayer` moves it, which is right
for a frame that says a player was on. A kick is done *to* somebody who may be offline, and
`last_seen` is presence (§23). Clan events note names with an upsert that leaves `last_seen`
alone.
### 24.6 What is not proven here
- **A real player's name on a feed line.** The synthetic Steam ids have none, so the rig's lines read
"A player founded the clan". Named lines need a real player's account on the rig, which is a walk
to run beside the §12.5 identity walk.
- **Staff do not see the members-only feed.** Core's feed access is membership and grants; a
moderator reads the roster (D48 includes staff) but not the members-only lines. That is core's
rule rather than this module's, and it is written down rather than changed.
- **The 100-clan ceiling was not reached on a rig.** It is the game's own clamp, read from its
source, and the truncation path is covered by the suite.
- **A Nexus server was not available.** Its refusal is read from the game's backend selection
(`NexusServer.Started` chooses `nexus`), not walked.
### 24.7 Smaller things worth keeping
- **The rig helper** (`RgClanRig.cs`, rig-only, never shipped) is ninety lines against
`ClanManager.ServerInstance.Backend`: create, add (invite plus accept), role, kick, colour,
disband, list, and a fake uMod disband. It is the cheapest way to fire every clan hook without
players, on either framework.
- **swagger-autogen drops a backtick inside a parameter description** and reports success; the
module's fragment check caught it (`2 annotation(s) swagger-autogen could not parse`).
- `ListTopClans` returns a pooled list that the plugin does not return to the pool. The GC collects
it; it is noted here so nobody "fixes" it into a double free.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
--- ---

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning ## 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: **four** places that must agree:
| Where | Repo | | 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** - ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10** - ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
- plugin configuration edited from the site (phase 7b) - ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
- clans, for core's Team provider (phase 9) - ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
- leases, budgets and the event actions (phases 12-13) - leases, budgets and the event actions (phases 12-13)
- the map image over the asset-bridge shape (phase 14) - 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. 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.