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
This commit is contained in:
2026-09-21 22:59:24 -05:00
parent 4fe8864939
commit bba2ab04e0
4 changed files with 441 additions and 6 deletions

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