1 Commits

Author SHA1 Message Date
bba2ab04e0 docs(modules): phase 7 as built — the permission mirror, and the set arithmetic behind it
Protocol 4 (`PROTOCOL.md` §10), phase 7 as built (`PLAN.md` §20), what an operator
needs to know about it (`INTEGRATION.md`), and the in-game leg as a walk to run
(`PLAYER_WALK.md`).

**The spec.** One verb carrying the whole desired set, diffed by the plugin
against the live store; a report whose two interesting fields are the ways a push
looks like it worked and did not (`unresolved`, `pending`); drift as a report
rather than an action; and the permission hooks as a live SIGNAL rather than the
record — a hook that stops firing costs latency, not correctness.

**The finding the design turns on, written where it belongs.** 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 have opposite correct answers. The store records who
granted a permission nowhere, so only the website can tell them apart — which is
why it keeps a ledger of what it pushed, and why revoking a hand edit needed a
table of its own.

**§10.5 is a rule generalising.** "A wedged sidecar must never stall the game"
becomes "nothing the far side sends may cost the main thread unbounded work",
because `perm.sync` is the first command whose work is not bounded by its own
shape. Three bounds, each on the side that can say something useful when it is hit.

**§20.7 says plainly what is not proven**: the acceptance line needs a second,
non-admin Steam account on the rig, and nothing in the plugin has been compiled.
The walk doc carries the seven steps, including the two things to confirm on
Carbon rather than assume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 22:59:24 -05:00
4 changed files with 441 additions and 6 deletions

View File

@@ -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 `<Mod>/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 `<h1>` 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

View File

@@ -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

View File

@@ -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 <steamid>`** | The permission is there, granted by this plugin rather than by hand |
| 4 | **At the console, `oxide.grant user <steamid> 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.

View File

@@ -50,8 +50,8 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **3** as of identity9) — declared in **four** places that
must agree:
The wire version is a single integer — **4** as of the permission mirror10) — 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.
---