diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 7b5fb10..0444422 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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 +> **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 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 | | 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 `/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 | -| 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** (D47–D58). 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 | | 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 | @@ -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 `::`.** The clan id alone is not an identity: the game's clan database is `clans..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 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 --- diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index 01f2b8a..bbb0b85 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -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 (D47–D58). + +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..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 `::` (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 members)`, and a Universal +form with `List`. 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.