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