feat(bridge): protocol 4 — the permission mirror, and the first command that writes #4

Merged
whitlocktech merged 1 commits from feat/phase-7-permissions into edge 2026-09-22 01:53:55 -05:00
Member

R2, the game half. The website is the author of record for groups and grants; this plugin makes the framework's own permission store match what it is sent, and reports everything it could not do.

One verb, and the plugin does the diffing (D32)

perm.sync carries the whole set the site authors for this server. The plugin compares it against the live store, writes only what differs, and answers with a report — what it applied, what was already right, the names it could not resolve, the memberships waiting on a first connection, and every holder the site did not author.

Shipping this server's store to the website and diffing there was rejected twice over: the store is the bigger of the two sets and would cross the wire constantly, and a website holding a copy has a second source of truth that is stale the moment it lands.

perm.catalogue is the other half — a live read of what the loaded plugins have registered. It is the option source the authoring form is built from, so a grant can only be written against a name that will actually resolve.

The first command whose work is unbounded, so it is drained rather than run

A community with two thousand linked players sends thousands of operations in one frame, and applying them in the tick the frame arrives is a freeze an operator will blame on the game. A sync is compiled into a list of single-store operations and drained two hundred at a time on a timer; the report goes back when the last one lands.

That is §5's rule — nothing the far side sends may cost the main thread an unbounded amount of work — applied to the inbound half for the first time. A sync larger than 20,000 operations is refused whole rather than applied for a while and abandoned, and a second sync arriving while one drains is answered busy rather than interleaved: two writers reconciling one store against two desired sets is a race with no correct outcome.

The two store facts the design turns on

  1. GrantUserPermission silently no-ops for an unregistered name (PLAN.md §12.2 rule 1) — void, no throw, no log. So every grant is behind a PermissionExists pre-check and an unresolved name is reported rather than granted. The plugin does not register the name itself (D33): that fabricates a permission the operator never installed.
  2. UserHasPermission cannot answer the question the diff asks. It is true for a member of a group that carries the permission, so a direct grant would look already applied the moment a group covered it — and a revoke already done. GetPermissionUsers reads the user's own set, which is what a direct grant is, and it is also how the foreign scan finds hand edits.

A group membership is read back after it is added, because that is the call that can do nothing at all: a player the store has never seen has no user record to place (§12.2 rule 4), and the API says so by returning void and changing nothing. Those become pending in the report.

Drift, at the weight it deserves

Six permission hooks join the read path — OnUserPermissionGranted / Revoked, OnUserGroupAdded / Removed, OnGroupPermissionGranted / Revoked — all void, all in ExpectedHooks, all emitting perm.drift.

They are not the drift record. Only the site's desired set can say whether a change is foreign, and that comparison happens in a sync. They are a reason to reconcile sooner: a console oxide.grant shows up on the website in seconds instead of at the next audit. That division is what makes them safe to trust at this weight — a hook that stops firing on a framework upgrade costs latency, not correctness.

They are suppressed while this plugin is writing, because they fire for our own writes too and the site cannot tell its own grant from a human's by looking at one.

Diagnostics

rg.perms prints the last report from the server console. It is the fastest way to tell the two silent failures apart: unresolved means no loaded plugin registered that name, pending means the store has never seen that player. Both look identical from the website — "it says they have it and the game disagrees".

Checks

node scripts/checkPlugin.js — protocol 4, 21 hooks declared, every one void and listed. Its own suite (14 tests) green. overlay.toml bumped in the same change as the emitters, so the bundle composes.

What is NOT proven here

Nothing in this file has been compiled. The plugin is deployed as source and built by Oxide or Carbon against game assemblies that exist only on a Rust server, and phase 7's rig walk is the org lead's call to run later (the acceptance line needs a second, non-admin Steam account — PLAN.md §12.5). The website half was walked end to end against the real sidecar with a stand-in speaking this protocol; the real store's semantics are what that stand-in imitates, and only the rig can confirm them — in particular GetPermissionUsers' entry format (id(name), spacing differs between calls and frameworks) and whether GetGroupPermissions(name, false) compiles on Carbon.

Base branch

edge, per D18.

AI disclosure

  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM

R2, the game half. The website is the author of record for groups and grants; this plugin makes the framework's own permission store match what it is sent, and reports everything it could not do. ## One verb, and the plugin does the diffing (D32) `perm.sync` carries the whole set the site authors for this server. The plugin compares it against the live store, writes only what differs, and answers with a report — what it applied, what was already right, the names it could not resolve, the memberships waiting on a first connection, and every holder the site did not author. Shipping this server's store to the website and diffing there was rejected twice over: the store is the bigger of the two sets and would cross the wire constantly, and a website holding a copy has a second source of truth that is stale the moment it lands. `perm.catalogue` is the other half — a live read of what the loaded plugins have registered. It is the option source the authoring form is built from, so a grant can only be written against a name that will actually resolve. ## The first command whose work is unbounded, so it is drained rather than run A community with two thousand linked players sends thousands of operations in one frame, and applying them in the tick the frame arrives is a freeze an operator will blame on the game. A sync is compiled into a list of single-store operations and drained two hundred at a time on a timer; the report goes back when the last one lands. That is §5's rule — *nothing the far side sends may cost the main thread an unbounded amount of work* — applied to the **inbound** half for the first time. A sync larger than 20,000 operations is refused whole rather than applied for a while and abandoned, and a second sync arriving while one drains is answered `busy` rather than interleaved: two writers reconciling one store against two desired sets is a race with no correct outcome. ## The two store facts the design turns on 1. **`GrantUserPermission` silently no-ops for an unregistered name** (PLAN.md §12.2 rule 1) — void, no throw, no log. So every grant is behind a `PermissionExists` pre-check and an unresolved name is reported rather than granted. The plugin does **not** register the name itself (D33): that fabricates a permission the operator never installed. 2. **`UserHasPermission` cannot answer the question the diff asks.** It is true for a member of a group that carries the permission, so a direct grant would look already applied the moment a group covered it — and a revoke already done. `GetPermissionUsers` reads the user's *own* set, which is what a direct grant is, and it is also how the foreign scan finds hand edits. A group membership is read back after it is added, because that is the call that can do nothing at all: a player the store has never seen has no user record to place (§12.2 rule 4), and the API says so by returning void and changing nothing. Those become `pending` in the report. ## Drift, at the weight it deserves Six permission hooks join the read path — `OnUserPermissionGranted` / `Revoked`, `OnUserGroupAdded` / `Removed`, `OnGroupPermissionGranted` / `Revoked` — all `void`, all in `ExpectedHooks`, all emitting `perm.drift`. **They are not the drift record.** Only the site's desired set can say whether a change is foreign, and that comparison happens in a sync. They are a reason to reconcile *sooner*: a console `oxide.grant` shows up on the website in seconds instead of at the next audit. That division is what makes them safe to trust at this weight — a hook that stops firing on a framework upgrade costs latency, not correctness. They are suppressed while this plugin is writing, because they fire for our own writes too and the site cannot tell its own grant from a human's by looking at one. ## Diagnostics `rg.perms` prints the last report from the server console. It is the fastest way to tell the two silent failures apart: `unresolved` means no loaded plugin registered that name, `pending` means the store has never seen that player. Both look identical from the website — "it says they have it and the game disagrees". ## Checks `node scripts/checkPlugin.js` — protocol 4, 21 hooks declared, every one void and listed. Its own suite (14 tests) green. `overlay.toml` bumped in the same change as the emitters, so the bundle composes. ## What is NOT proven here **Nothing in this file has been compiled.** The plugin is deployed as source and built by Oxide or Carbon against game assemblies that exist only on a Rust server, and phase 7's rig walk is the org lead's call to run later (the acceptance line needs a second, non-admin Steam account — PLAN.md §12.5). The website half was walked end to end against the real sidecar with a stand-in speaking this protocol; **the real store's semantics are what that stand-in imitates, and only the rig can confirm them** — in particular `GetPermissionUsers`' entry format (`id(name)`, spacing differs between calls and frameworks) and whether `GetGroupPermissions(name, false)` compiles on Carbon. ## Base branch `edge`, per D18. ## AI disclosure - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
wtclaude added 1 commit 2026-09-21 18:27:37 -05:00
feat(bridge): protocol 4 — the permission mirror, and the first command that writes
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
e041089080
R2, the game half. The website is the author of record for groups and grants;
this plugin makes the framework's own permission store match what it is sent.

**One verb, and the plugin does the diffing (D32).** `perm.sync` carries the
whole set the site authors for this server. The plugin compares it against the
live store and writes only what differs, then answers with a report: what it
applied, what was already right, the names it could not resolve, the memberships
waiting on a first connection, and every holder the site did not author.
`perm.catalogue` is the other half — a live read of what the loaded plugins have
registered, which is the option source the authoring form is built from.

**The first command whose work is unbounded, so it is drained rather than run.**
A community with two thousand linked players sends thousands of operations in one
frame, and applying them in the tick the frame arrives is a freeze an operator
will blame on the game. A sync compiles into a list of single-store operations
and drains two hundred at a time on a timer. That is §5's rule — nothing the far
side sends may cost the main thread an unbounded amount of work — applied to the
inbound half for the first time.

**The pre-check §12.2 rule 1 exists for.** `GrantUserPermission` returns void,
throws nothing and logs nothing for a name no plugin registered, so every grant
naming an unloaded plugin's permission would disappear without a trace. The plugin
checks `PermissionExists` first and reports the name as unresolved; it does NOT
register the name itself (D33), because that fabricates a permission the operator
never installed.

**`UserHasPermission` cannot answer the question the diff asks.** It is true for a
member of a group that carries the permission, so a direct grant would look
already applied the moment a group covered it — and a revoke already done.
`GetPermissionUsers` reads the user's own set, which is what a direct grant is.

Six permission hooks join the read path, all void, all in `ExpectedHooks`. They
are not the drift record — only the site's desired set can say whether a change is
foreign — they are a reason to reconcile sooner, and they are suppressed while
this plugin is writing, or every sync would announce itself as drift.

`rg.perms` reports the last report from the console, which is the fastest way to
tell "no plugin registered that name" from "the store has never seen that player".

Protocol bumped to 4 here and in overlay.toml, in the same change as the emitters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
whitlocktech merged commit bd8cb24ecf into edge 2026-09-22 01:53:55 -05:00
whitlocktech deleted branch feat/phase-7-permissions 2026-09-22 01:53:56 -05:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Rust-Plugins#4
No description provided.