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

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