docs(rust): phase 12 as built and walked — §27.5-27.7, PROTOCOL.md §14

Walked on both rigs through real core: 21 keys observed live, the game's own deadline with core stopped, drift, reload and restart mid-hold, and the switch. Records two defects fixed in the phase and four found outside it and raised: the phase-7 grant owner bug, core restoring orphaned lease rows over a later run, core.lease dropping retry:false, and the 10s budget squeeze.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 21:14:09 -05:00
parent 46b565ac9b
commit 5f18097223
2 changed files with 252 additions and 2 deletions

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **7** as of the raid frame (§13) — declared in
The wire version is a single integer — **8** as of the leases (§14) — declared in
**four** places that must agree:
| Where | Repo |
@@ -205,6 +205,7 @@ Every response carries `X-RustLink-Version`, including `/health` and including e
| `GET /feed?since=&limit=` | the store | **Oldest first**, from a cursor. For a consumer that must not miss a row (§8.9) |
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
| `GET /lease`, `POST /lease`, `POST /lease/release` | the plugin (RPC) | Protocol 8, the leases (§14) |
### 4.1 The split between store-backed and live is deliberate
@@ -248,6 +249,9 @@ sidecar RPC timeout (10s) < module client timeout (12s) < an action's budget
Derive one from another rather than writing all three down independently.
**Lease calls are the exception, and a deliberate one** (§14.7): `core.lease` spends one default
budget on two calls, so the module's lease timeout is *shorter* than the sidecar's.
---
## 5. What the plugin owes the game
@@ -1253,3 +1257,145 @@ they arrive (§8.1). The bump exists because a website that alerts on `authorize
a protocol-6 plugin that never sends it — against one it would read every raid as a base with no
cupboard and alert nobody while looking healthy.
## 14. Protocol 8 — the leases
Added in phase 12 ([`PLAN.md`](../modules/rust/PLAN.md) §27). An event borrows a value on a server
and gives it back. **Three commands, one event and one plugin config key.** The plugin holds the
allowlist, the bounds, the seven-day ceiling and the deadline. The website holds the ledger (core's
`core.lease`). This process forwards three routes and learns nothing about either side.
The shape is UO's lease plane (`link/v6.md` §8), and its three rules carry over unchanged:
- **`holdMs` is authoritative and `untilMs` is display.** An absolute deadline computed on the
website and honoured on the game host is measured against two clocks.
- **Values cross as text and compare parsed.**
- **A hold over the ceiling is refused, never clamped.**
What differs from UO is what Rust's convars and permission store are like (§14.4).
### 14.1 The commands
```json
{"cmd":"lease.apply","reqId":"r-7","key":"decay.scale","family":"decay","value":"0",
"holdMs":3600000,"untilMs":1790000000000}
```
| Command | Answers | |
|---|---|---|
| `lease.list` | `lease.list` | Every allowlisted key with `family`, `min`/`max`, `current` (or `unreadable` with a reason), `held`, and while held `baseline`/`applied`/`untilMs`/`runId`. Plus `holds` (every hold in force), `eventsEnabled` and `maxHoldMs`. **Narrowed by `key`, and by `target` for a group permission**, which has one value per pair rather than one per key |
| `lease.apply` | `lease.ok` or `lease.error` | `lease.ok` carries `baseline`, `applied` and `untilMs` |
| `lease.release` | `lease.ok`, `lease.drifted` or `lease.error` | Compare-and-set. `lease.ok` carries `restored`, or `targetGone: true` for a group deleted mid-hold |
**The allowlist is the plugin's.** It holds `decay.scale` (family `decay`, 0–10), eighteen animal
and vehicle `*.population` convars (family `population`, 0–50, **all per square kilometre**, vehicles
included, whatever the game's help text says), and `spawn.min_rate` and `spawn.min_density` (family
`spawn`, 0–10). Every key was walked live: set, seen changing the game's own computation, and given
back. The two `spawn.max_*` scalars only matter with players online, and no walk has had any, so
they are not lent (PLAN.md §27.5). A `family` sent with an apply must match, so a website that confused two
leases is refused rather than obeyed. The one key that is not a convar is `group.permission`, whose
`target` is `group/permission` (split at the **last** slash, because a group name is free text and
a permission name never contains one) and whose value is `true` or `false`. **The plugin grants with
a `null` owner.** Given an owner, Oxide's `GrantGroupPermission` first checks that *that* plugin
registered the name, and returns silently when it did not. Every permission a lease borrows
belongs to another plugin, so the call has to name none.
**`lease.error` reasons**, each with a `message` meant for an operator:
| `reason` | Means | Worth retrying |
|---|---|---|
| `events-disabled` | `EventsEnabled` is off on this server (§14.3) | no |
| `unknown-key` | not a value this server lends, or not in the family named | no |
| `out-of-range` | outside the plugin's own bounds for the key | no |
| `too-long` | `holdMs` over seven days | no |
| `unresolved` | a group permission naming a permission no loaded plugin registered | no |
| `target-gone` | the group does not exist | no |
| `malformed` | a field is missing or unparseable | no |
| `unreadable` | the current value could not be read this moment | yes |
| `refused` | the game did not take the value: it read back as something else, and the old value was put back | yes |
### 14.2 The two mechanisms
**The deadline lives on the game.** A hold is checked every second. When its deadline passes, the
plugin restores the baseline (compare-and-set, as a release would) and emits `lease.expired`, whether
or not the website is ever heard from again:
```json
{"kind":"lease.expired","type":"event","key":"decay.scale","runId":"77","drifted":false}
```
The website maps it to nothing. Core learns what happened through `restore` and `inForce`, just as
UO's website does.
**Release is compare-and-set.** The comparison is against what the lease applied, taken from the
plugin's own record of the hold when it has one, else from the website's `expected`. A current value
that is neither what was applied nor what would be restored was moved by somebody on purpose. The
answer is `lease.drifted` with that value, the world is left alone, and the hold is over. A current
value that already equals the baseline is a success and nothing is written, which is what a release
finds after a deadline or a restart has already given the value back. **A drifted release is a
`200`**: the plugin did what it was asked.
**An apply of a key already held keeps the original baseline.** Core reserves the target before it
applies, so a second holder is refused on the website's side. A second apply arriving here therefore
means the first one's answer was lost and core is trying again. The value to give back is still what
was there before anybody borrowed it.
### 14.3 `EventsEnabled`
A new key in the plugin's config, **`false` by default** (D76), written into an existing config the
first time protocol 8 loads so that the site's config editor can show it. It gates **`lease.apply`
only**. Listing and releasing always work, so switching events off never strands a value somebody
already borrowed.
It is its own switch for UO's reason: a scheduled change to the world at four in the morning is a
different consent from a permission sync or a moderation action.
### 14.4 What a restart gives back, and what it does not
**No allowlisted convar is `Saved`.** The game writes the `Saved` set to `serverauto.cfg`, and none of
these is in it. So a convar hold is memory-only, and **a restart is a free restore**. The plugin
checks rather than trusts: at load it refuses any allowlisted convar whose `Command.Saved` is true,
with a reason in `unreadable`.
**A group permission is persisted by both frameworks**, so a hold on one survives a crash, a restart
and a plugin reload. The plugin therefore keeps its own record, `leases.json` under its data
directory (which R18's editor never walks), with the `bootId` each hold was taken under:
| On load | A convar hold | A group-permission hold |
|---|---|---|
| **Same boot** (a plugin reload) | re-armed: the value is still in the game's memory, and a config save must not end an event | re-armed |
| **New boot** | dropped: the restart restored it | re-armed, and restored at once if its deadline passed while the server was down |
Holds are **not** given back on unload. The file keeps them.
### 14.5 The permission mirror defers to a lease
A `(group, permission)` pair held by a lease belongs to the lease until the hold ends. `perm.sync`
neither grants nor revokes it, and the scan never reports it `foreign`. It is listed in the report's
new **`leased`** array. What the site asked for in the meantime is recorded on the hold, and at
release the pair is set to **that** rather than to the baseline: the lease borrowed the pair, and the
site owns what it becomes afterwards. The plugin's own lease writes raise no `perm.drift`.
### 14.6 The sidecar
`PROTOCOL_VERSION` becomes 8. Three routes, each a correlated round trip that fails when the game is
down:
| Route | Command | |
|---|---|---|
| `GET /lease?key=&target=` | `lease.list` | Both query fields optional and forwarded as they are |
| `POST /lease` | `lease.apply` | Opaque object; `cmd` and `reqId` written over the caller's |
| `POST /lease/release` | `lease.release` | The same |
`lease.expired` is an `event`, filed and served like every other (§8.1).
### 14.7 The website's timeout, again
`core.lease` declares no `budgetMs`, so it runs under the dispatcher's default of **10 s**, and it
makes **two** calls into the module inside that (`read`, then `apply`). `module-rust` therefore gives
lease calls their own client timeout of **4.5 s** (`LEASE_TIMEOUT_MS`), so that two fit inside the
budget. A test asserts the sum.
That is below the sidecar's 10 s reply timeout, so the module can give up on an apply the game is
still going to take. It follows a timed-out apply with a release of the same value down the same
link. The plugin handles the two in order: if the apply landed, the hold's own baseline goes back,
and if it never did, the compare finds nothing to do.