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