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

@@ -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 `<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 |
| 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* **Plan of record in §24** (D47D58), incl. the 100-clan ceiling the game imposes (D55). 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 |
| 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 |
@@ -3501,6 +3505,105 @@ The phase row's criterion: *the clan page is ours, core's contributions land in
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

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.