diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 0e8a259..a9b0115 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -984,7 +984,7 @@ Each phase ends with its findings written down, as every workstream here does. | 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off | | 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged | | 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page | -| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | 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). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | 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). Identity and permission surfaces | Android-app | A player links from the app | | 9 | **Teams from first-party clans** (R5). 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 | @@ -2885,6 +2885,189 @@ with no game behind it, one address with nothing listening) and three logins: --- +## 20. Phase 7 as built — site-owned permissions, 2026-09-21 + +**The site's half is built and walked; the in-game half is written down as a walk to run.** R2 is +the direction the Integration Kit has no chapter for (§2, R2), and building it once is what phase 19 +was told to wait for. It is also the first phase where this module writes to a game. + +Five repositories touched: `Rust-Plugins#4`, `Rust-Link#4`, `Module-Rust#8` (all into `edge`), this +document, and nothing in `website` — core needed no change, which is itself worth recording after +phases 5 and 6 both found something missing in the contract. + +### 20.0 The seven decisions this phase needed + +| | Decision | +|---|---| +| **D28** | **A grant is keyed to the website USER**, resolved to every Steam id they have linked at push time. Not to a Steam id: the site authors privilege for a *person*, phase 13's earned entitlements follow whoever earned them, and unlinking an account takes its privileges with it. The cost, which is real and appears again in §20.5, is that a person with two linked accounts holds it on both | +| **D29** | **Every authored row carries a scope** — one server id, or `*` for the fleet. A modded server and a vanilla one will not want one set on both, and a single-server community never sees the choice | +| **D30** | **Groups are mirrored as real groups**, not flattened into per-player grants. Third-party plugins read group membership, R15's BetterChat group API (phase 17) has something to hang on, and an operator reading `oxide.show groups` sees what the website shows. The price is §12.2 rule 4: a player the store has never seen cannot be put in a group, while a direct grant to the same account works immediately | +| **D31** | **A holder the site did not author is reported, never undone**, and an operator is offered two answers: adopt it (the site maintains it from then on) or revoke it (removed on the next sync). A console grant during an incident must survive the next reconcile | +| **D32** | **One verb, and the plugin does the diffing.** The site sends the whole desired set; the plugin compares it against the live store and writes only the difference. Shipping the store to the website was rejected: it is the bigger of the two sets, and a copy of it is a second source of truth that is stale the moment it lands | +| **D33** | **Never self-register a permission.** The form offers what the servers report; a name that stops resolving is reported unresolved and the grant is kept. §12.2 rule 3 says `RegisterPermission` would make it stick with a console warning — doing so fabricates a permission the operator never installed, inert until some plugin happens to check it | +| **D34** | **People and groups, by hand.** Rules that keep themselves true — "everyone in the Donators team is in `vip`" — are genuinely wanted and are a second authority over the same rows, with its own reconcile. Deferred | + +### 20.1 Three sets, and every interesting question is a difference between two + +``` +desired − pushed apply +pushed − desired RETIRE, because the site put it there and has since withdrawn it +present − desired drift +``` + +The middle row is the whole reason `rust_perm_pushed` exists, and it is the part that cannot be +inferred from anything the game knows. **A name in the store that is not in the desired set is +either something the site retired or something a human granted**, and those two have opposite +correct answers. The store records who granted a permission nowhere, so the site's own memory of +what it pushed is the only thing that tells them apart. + +That table is keyed by **Steam id**, not by user, because it records what is in the GAME and the +game has never heard of a website account. Unlinking therefore leaves its rows until the next sync +retires them, which is correct and would be inexpressible keyed the other way. + +**Revoking drift needed its own table**, and the reason is a good one: a foreign grant often names a +Steam id no website account holds, so there is no user to author it against and nothing in +`rust_perm_pushed` to remove. `rust_perm_revocations` is an instruction with its own lifetime — +queued by a person, carried in the next sync's `retire` list, deleted when a report comes back. A +server that is offline keeps the instruction until it returns, which is what an operator expects +from a site that claims to be the author of record. + +### 20.2 What lands is not what was sent + +Two outcomes look exactly like success from the website and are not, and both are reported by the +plugin rather than assumed: + +- **`unresolved`** — no loaded plugin on that server registered the name, and `GrantUserPermission` + no-ops silently for one (§12.2 rule 1). This is the finding with teeth that phase 0 produced, and + phase 7 is where it is actually paid for. +- **`pending`** — the store has never seen that player, so a group membership cannot be placed + (§12.2 rule 4). The plugin reads the membership back after adding it, because a void return and no + change is the only signal available. + +**Neither is recorded as pushed.** A site that recorded them would believe it had given a privilege +it had not — and would later "retire" it from a server that never had it, a no-op that reads as a +success in every log. + +### 20.3 The loop, and what provokes it + +Every 30 seconds it compares a digest of the desired set against what each server last confirmed, +and does nothing when they match. A sync happens when the set changed, when the game restarted or +wiped (the boot-id and wipe-id watch), when a `perm.drift` hook arrived, when the last attempt +failed and its backoff has elapsed, or every 15 minutes regardless — the audit is what finds drift +on a server nobody has touched. + +**The digest is sorted before hashing.** The rows come out of five queries in an order nothing +guarantees, and an unsorted digest differs between two reads of an unchanged set — which would push +to every game server on every tick, for ever. + +`dirty` is an optimisation rather than the truth, and it is written down as such: the loop's real +condition is `desired_hash != synced_hash`, recomputed from the tables every tick, so a flag cleared +while a sync was in flight costs nothing. + +### 20.4 Nothing the far side sends may cost the main thread unbounded work + +`perm.sync` is the first command whose work is not bounded by its own shape. A community with two +thousand linked players sends thousands of store operations in one frame; applying them in the tick +they arrive is a freeze an operator will blame on the game. + +The plugin compiles a sync into single-store operations and drains 200 at a time on a timer, +reporting when the last one lands. Compiling touches nothing, so an oversized or malformed sync is +refused before there is any state to unwind. **This is §5's no-stall rule pointed at the inbound +half**, and it is new: every earlier command was a request to repeat something the game already knew. + +The three bounds are each on the side that can say something useful when one is hit — ~15,000 rows +at the website (which can name the server to an operator), 1 MiB at the sidecar (the game link's own +line cap; forwarded, the line is discarded silently and presents as a `504`), 20,000 operations at +the plugin. + +### 20.5 Four defects a browser walk found that 133 green tests did not + +1. **A person with permissions and no linked Steam account had the whole Rust section hidden** on + core's user page. The section was gated on `links.length`, phase 6's rule, and phase 7 gave it a + second reason to exist — *for exactly the person whose grants reach nobody*, which is the state an + operator most needs to see. It is the same class of defect as phase 6's invisible refusal: a + correct decision that a later phase quietly invalidated. +2. **A member waiting on a first connection looked like an ordinary member.** The count was in the + server strip; nothing was beside the person. Fixed by matching each member's accounts against the + `pending` entries in every server's report — which also required the overview to carry *all* of a + member's Steam accounts rather than whichever one the join returned first. +3. **A grant naming an unregistered permission carried no warning**, though a group's permission + list had carried one from the start. Same fact, two places, one of them missed. +4. The page drew its own `

` under core's chrome title — the same words twice. + +**A fifth was found by a test, and it was the better catch.** `buildDesired` resolved a grant's +Steam accounts from the **join** in `listGrants` rather than from the link map, which gives the +right answer by accident: the join repeats a grant per linked account. It would have kept giving the +right answer until somebody changed that query, at which point one of a person's two accounts +quietly stops being granted. The test that caught it was written against a fixture with one row per +grant — the fixture was wrong about the query and right about the model. + +### 20.6 What was proven, and how + +A real core at the pinned ref with the module installed, the real `rust-link` sidecar, and a +**stand-in plugin** speaking protocol 4 on the loopback — a Node script implementing the store +semantics that matter (unregistered names no-op; an unseen player can hold a grant but not a +membership; `GetPermissionUsers` answers direct holders only). + +- a group created, its permission added and two memberships applied on the first sync — two, because + one member holds two linked Steam accounts (D28 on the wire); +- `unresolved: ["kits.gold"]` and `pending: ["7656003:vip"]` reported, and **absent from + `rust_perm_pushed`**; +- a hand edit found as drift, adopted, and the refusal for one whose Steam id belongs to nobody: + *"That Steam account is not linked to any account on this site, so there is nobody to author this + against."*; +- a drift row revoked — queued, carried on the next sync (`revokes: 1`), the queue row cleared; +- a withdrawn grant retired (`pushed − desired`), and its pushed row removed; +- **a restart that emptied the store**: `reason: "restart"`, the whole set re-pushed, group, + permission, memberships and grant all back. That is R2's central promise, walked; +- a `perm.drift` frame marking the server dirty through the ingest, and the next sync reporting the + hand edit authoritatively — the live signal and the authoritative answer, in the division §10.4 + describes; +- an unreachable second server reporting `transport-error` and retrying on its backoff while the + first stayed in sync; +- the module's chunk alone in the browser console: no second React, no bare import. + +### 20.7 What is NOT proven, and who it is waiting on + +**The acceptance line — "a grant made on the website gates a third-party plugin in-game" — is not +met**, and the reason was known before the phase started (§12.5): no console session can observe a +permission gate, and an admin account bypasses PopupNotifications and ZoneManager unconditionally. +It needs a **second, non-admin Steam account** on the rig, which is the org lead's to arrange. The +phase was built on the understanding that the in-game leg becomes a walk to run (as D27 did for +identity), not a claim. + +**Nothing in the plugin has been compiled.** Two specifics for whoever runs that walk: + +- `GetPermissionUsers` and `GetUsersInGroup` answer with `id(name)` and the spacing differs between + the two calls and between the frameworks. The plugin takes everything before the first bracket; + confirm it on both rigs, because a parse that is wrong here reports every holder as foreign. +- `GetGroupPermissions(name, false)` is called with both arguments. If Carbon's signature has no + second parameter this does not compile there — the one place in this change where R19's + "byte-identical plugin" claim is at risk. + +### 20.8 Smaller things worth keeping + +- **A mount prefix is ONE path segment.** Core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`, so + `/rust/permissions` cannot be declared in `module.json` and has to be a nested `use()` under + `/rust`. The useful half: **swagger-autogen follows the `require` and generates it with the right + prefix anyway** — the exact opposite of phase 6's hole, where the registration walk could not see + a slot router. The generator sees a nested router the walk cannot. +- **This module's first admin page**, and the first thing in it that had to be *authored* rather + than configured. The server rows are still configured through the API and have no screen — + a gap this phase deliberately did not widen into. +- **There is no module-declared site permission at MODULE_API 1.10.0**, so `requireRole('admin')` on + every route is the whole of the available vocabulary. R18's "its own site permission" for the + config editor needs a contract member that does not exist; phase 7b will meet the same wall. +- **Adopting a hand grant widens it.** It becomes a grant against the *person*, so it reaches every + Steam account they hold — one consequence of D28 that only shows up when adopting, and the next + sync applies it to their other accounts. Correct, and worth saying out loud on the screen one day. +- The rig had a leftover core **and** a leftover sidecar from phase 6 still running, and the sidecar + held the release binary open so `cargo build` failed with `Access is denied (os error 5)` and + **exit code 0**. Two sessions' rigs on one machine is now a standing hazard; check ports 3200, + 8090 and 7799 before starting anything. + +--- + [rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link [rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins [mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust diff --git a/rust-link/INTEGRATION.md b/rust-link/INTEGRATION.md index f2aa057..fd54351 100644 --- a/rust-link/INTEGRATION.md +++ b/rust-link/INTEGRATION.md @@ -235,6 +235,31 @@ Two things an operator should know about it: refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in the admin panel. +**From protocol 4 the website owns your permissions.** Groups and grants are written in +Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every +plugin you already run honours them — Kits, ZoneManager, anything that calls `UserHasPermission`. +Nothing is required of those plugins and nothing is configured twice. + +Four things an operator should know about it, because each looks like something else from the game +side: + +- **A wipe does not lose them.** The site re-pushes the whole set when the server comes back. If your + wipe script clears `oxide/data/`, the permissions the site authored are back within a minute of the + server being up; ones granted at the console are not, because nothing remembers those. +- **Granting at the console still works, and the website notices.** A hand edit is reported as + drift on that screen and is **never** undone on its own — an operator is offered two answers to + it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console + grant during an incident must survive the next sync. +- **A permission no loaded plugin has registered cannot be granted.** Oxide's own API silently does + nothing for an unknown name, so the site checks first and reports the name as unresolved instead + of claiming a privilege nobody has. Load the plugin and the grant lands by itself. +- **A player who has never connected to that server can hold a grant but cannot be in a group.** + The store has no record of them to put in a group yet; the site says which memberships are waiting + and they land on that player's first connection. + +`rg.perms` at the server console prints what the last sync did, which is the fastest way to tell +"that permission does not exist here" from "that player has never been seen here". + **Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and stamps it on every frame, so a wipe splits the history rather than ending it. That is also why **the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's diff --git a/rust-link/PLAYER_WALK.md b/rust-link/PLAYER_WALK.md index 5d08631..ad5b39e 100644 --- a/rust-link/PLAYER_WALK.md +++ b/rust-link/PLAYER_WALK.md @@ -127,3 +127,50 @@ unreachable right now"* instead, which is the same rule with nothing left to be for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website account at a time, and every refusal is a sentence that tells the player what to do next. + +--- + +## The permission walk (protocol 4, phase 7) + +Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring +screen, the report, drift and its two answers, and a restart that emptied the store and was fully +re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a +third-party plugin in the game.** + +It cannot be walked from a console, and it cannot be walked on the owner's account: + +- **A console session bypasses every gate.** The standard idiom is + `return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer` — + so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)). +- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications + (`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the + exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions` + defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner. + +So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be +walked on the owner's account; steps 4 and 5 cannot. + +| # | Do this | You should see | +|---|---|---| +| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen | +| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase | +| 3 | **At the server console, `oxide.show user `** | The permission is there, granted by this plugin rather than by hand | +| 4 | **At the console, `oxide.grant user zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own | +| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove | +| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed | +| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately | + +**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and +this phase has two specific things to confirm there rather than assume: + +- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing + differs between the calls and between the frameworks. The plugin takes everything before the first + bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible + immediately: the drift list fills with grants the site itself made. +- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no + second parameter, the plugin does not compile there at all — the one place in protocol 4 where + R19's byte-identical-plugin claim is at risk. + +**What counts as a pass:** a non-admin player's access in game changes because of something typed on +the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the +operator nothing. diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index 04a6fe5..83e1fcd 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -50,8 +50,8 @@ it is listening without one. ## 2. Versioning -The wire version is a single integer — **3** as of identity (§9) — declared in **four** places that -must agree: +The wire version is a single integer — **4** as of the permission mirror (§10) — declared in +**four** places that must agree: | Where | Repo | |---|---| @@ -330,11 +330,12 @@ writing the file and generating the token if they are missing — and prints it ## 7. What is deliberately not here yet -Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs -it, and each is a version bump: +Protocol 4 is the transport, the read path, identity and the permission mirror. Every one of these +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), and plugin configuration edited from the site (phase 7b) +- ~~the permission mirror (phase 7)~~ — **protocol 4, §10** +- plugin configuration edited from the site (phase 7b) - clans, for core's Team provider (phase 9) - leases, budgets and the event actions (phases 12-13) - the map image over the asset-bridge shape (phase 14) @@ -702,3 +703,182 @@ is wrong — so they fetch another code from the same server and hear it again. --- + +## 10. Protocol 4 — the permission mirror + +R2, and the first command on this bridge that **changes the game**. Protocol 3's +`link.confirm` was the website originating a message, but it spent a code the game +itself had minted; this writes to a store the game enforces. + +``` + website sidecar plugin + │ │ │ + ├── POST /permissions/sync ►│ ──── perm.sync ─────────►│ diff against the + │ the whole desired set │ (the same object) │ live store, apply + │ │ │ the difference in + │◄──── the report ──────────│◄──── perm.report ────────┤ bounded steps + │ │ + │◄──── perm.drift (event) ──────────────────────────────┤ somebody else wrote +``` + +**The website is the author of record and the framework's store is an enforcement +cache.** Every third-party plugin honours a site grant with no adapter, because +they all already call `permission.UserHasPermission` — reaching them is the point, +and it is why the site does not keep a private table of its own. + +### 10.1 One verb, and the PLUGIN does the diffing + +`perm.sync` carries the whole set the site authors **for that server**. The plugin +compares it against the live store and writes only what differs. + +The alternative — the plugin reporting its store and the website computing the +difference — was rejected for two reasons. The store is the bigger of the two sets +and would cross the wire constantly, and a website holding a copy of it has a +second source of truth that is stale the moment it lands. + +```json +{ + "cmd": "perm.sync", + "reqId": "r-42", + "setId": "69dfc769…", + "groups": [ + { "name": "vip", "title": "VIP", "rank": 10, + "permissions": ["kits.vip"], + "members": ["76561198000000001", "76561198000000002"] } + ], + "grants": [ + { "steamId": "76561198000000001", "permissions": ["kits.gold"] } + ], + "managed": ["kits.vip", "kits.gold"], + "retire": [ + { "kind": "grant", "subject": "76561198000000003", "object": "kits.silver" } + ] +} +``` + +| Field | Means | +|---|---| +| `setId` | the site's digest of the set, echoed in the report. It is how the site knows a report describes the set it sent rather than an earlier one | +| `groups` | group definitions, what each carries, and who is in it. **Three separate facts**, because the game can fail at each independently | +| `grants` | permissions held by one account without a group | +| `managed` | the permission namespace the site claims. Foreign holders are only looked for within it — which also bounds the scan by the site's own set rather than by the size of the store | +| `retire` | what the site put there and has since withdrawn (§10.3) | + +### 10.2 `perm.report` — what actually happened + +```json +{ + "kind": "perm.report", "type": "reply", "reqId": "r-42", "setId": "69dfc769…", + "applied": { "grants": 1, "revokes": 0, "groupsCreated": 1, "groupPermissions": 1, + "members": 2, "membersRemoved": 0, "groupsRemoved": 0, + "groupPermissionsRemoved": 0 }, + "alreadyCorrect": 14, + "absent": 0, + "unresolved": ["kits.gold"], + "pending": ["76561198000000003:vip"], + "foreign": [{ "kind": "grant", "subject": "76561198000000009", "object": "kits.admin" }], + "operations": 4 +} +``` + +**`unresolved` and `pending` are the two ways a push looks like it worked and did +not**, and both are load-bearing: + +- **`unresolved`** — no loaded plugin on that server has registered the name. + `permission.GrantUserPermission` returns void, throws nothing and logs nothing + for an unregistered name ([PLAN.md §12.2](../modules/rust/PLAN.md) rule 1), so + without the `PermissionExists` pre-check the grant vanishes without a trace. The + plugin does **not** register the name itself: that fabricates a permission the + operator never installed. +- **`pending`** — the store has never seen that player, so there is no user record + to put in a group (§12.2 rule 4). A **direct grant** to the same account works + immediately, and the asymmetry is exactly why groups are not the only shape the + site can express. The membership lands on their first connection. + +Neither is recorded by the website as pushed. A site that recorded them would +believe it had given a privilege it had not — and would later "retire" it from a +server that never had it, which is a no-op that reads as a success in every log. + +**A refusal of the whole sync is `perm.error`**, with a reason of `busy` (an +earlier sync is still draining) or `too-large`. Like `link.error` it is a `200` +from the sidecar: the transport worked and the game answered. + +### 10.3 Retirement is the one thing the game cannot work out + +A name in the store that is not in the desired set is **either** something the site +authored and has since withdrawn **or** something a human granted at a console — +and those two have opposite correct answers. The store records who granted a +permission nowhere, so only the website can tell them apart, from its own memory of +what it pushed. + +So the site sends `retire` explicitly, and everything else it did not ask for comes +back as `foreign`. **Nothing in `foreign` is ever removed by a sync** (D31): a +console `oxide.grant` during an incident is drift, not an error, and an operator is +offered two answers to it on the website — adopt it, or revoke it. + +### 10.4 `perm.drift` — a reason to reconcile, not the reconciliation + +Both frameworks raise a hook for every permission write. The plugin subscribes to +six of them and emits `perm.drift` for writes **it did not make itself**, staff +class (§8.5): it names a Steam id beside a privilege, which is a fact about a +person's standing rather than about what happened on the server. + +```json +{ "kind": "perm.drift", "type": "event", "action": "granted", + "steamId": "76561198000000009", "permission": "kits.admin" } +``` + +`action` is one of `granted`, `revoked`, `group-added`, `group-removed`, +`group-permission-granted`, `group-permission-revoked`. + +**It cannot say whether the change is foreign** — only the desired set can, and +that comparison happens in a sync. So the website treats the frame as a reason to +reconcile *soon*: a hand edit shows up in seconds instead of at the next audit, and +the authoritative answer still arrives as a report. That division is what makes the +hooks safe to trust at this weight: one that stops firing on a framework upgrade +costs latency, not correctness. + +The plugin suppresses them while it is applying a sync, because they fire for its +own writes too — and the site cannot tell its own grant from a human's by looking +at one. + +### 10.5 Nothing the far side sends may cost the main thread unbounded work + +This is the first command whose work is **not** bounded by its own shape. A +community with two thousand linked players sends thousands of store operations in +one frame, and applying them in the tick the frame arrives is a freeze an operator +will blame on the game. + +So a sync is compiled into a list of single-store operations and drained a few +hundred at a time on a timer; the report goes back when the last one lands. +Compiling touches nothing, so an oversized or malformed sync is refused before any +state exists to unwind. That is §5's rule — the one that keeps a wedged sidecar +from stalling the game — pointed at the inbound half. + +Three bounds, each on the side that can say something useful when it is hit: + +| Bound | Where | Why there | +|---|---|---| +| ~15,000 rows | the website | it can name the server and reach an operator | +| 1 MiB | the sidecar | it is the game link's own line cap (§3.1); forwarded, the line is discarded silently and presents as a `504` | +| 20,000 operations | the plugin | past it, a half-applied permission set is the state nobody can reason about | + +### 10.6 `GET /permissions/catalogue` + +A live round trip to the plugin: every permission the loaded plugins have +registered, and the groups the store holds. It is the option source behind the +website's authoring form — a grant can only be written against a name that will +actually resolve — and, like `/status`, it fails when the game is down, because +"what exists right now" has no stale answer worth giving. + +### 10.7 What the sidecar does NOT do + +It defines no schema for either body. Protocol 4 adds the largest command on this +bridge and touches neither the store nor the feed, which is §8.1's dumb-forwarder +property paying for itself a second time. + +What it does own is the envelope: `cmd` and `reqId` are written over whatever the +caller sent, so no request can arrive claiming to be a different command or aimed +at a correlation id somebody else is waiting on. + +---