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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user