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

@@ -998,7 +998,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* ✅ **Built 2026-09-23 — plan, as built and findings in §24** (D47–D58). Walked on the Oxide rig end to end and on Carbon hook by hook; the game's 100-clan ceiling accepted (D55); **one module per site** became a core rule on the way (§24.5). 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 |
| 10 | **Notifications and engagement** (R7). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §25 (D59–D68).** The criterion walked on the Oxide rig (two authorised owners alerted once each; bystander, raider and an unlinked owner told nothing), and the same plugin loaded on Carbon; a live walk found that every generic notice never said which server (§25.6). Streams, triggers with `ceiling` and `subjectKey`, audiences and engagement seeds — **the catalogue is §10, as corrected by §25.2**. The announce leg and the post hook are **not** in it (D62 amends R7), and protocol 7 widens the raid frame, which brings the two bridge repositories in | all 3 + docs | The offline raid alert reaches the player whose base it was, and nobody else |
| 11 | **Android leg C** (R10). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §26 (D69–D72).** The link check behind personal streams asked `module-uo` and locked the raid alert on every Rust site; it now asks the site's own module and holds back only push-on. A tickle is titled from the inbox row it names, and two links stopped opening the browser. **The relay hop was walked on `ntfy.sh` for the first time on any site**: a rig raid reached the emulator in 19 seconds, and after push was switched off in the app the next raid enqueued in-app only | Android-app + docs | A Rust notification arrives on a phone and can be switched off there |
| 12 | **Events: option sources and the leases** (§9, **as corrected by §27**). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised. **Plan of record in §27 (D73–D79)**: the target names the server, game convars only (vanilla Rust has no gather/craft/smelt rate), the weekend-VIP lease is a group-wide permission, an `EventsEnabled` switch off by default, seven-day holds, and no budget dimension until phase 13; protocol 8 | Module-Rust + 2 + docs | A leased value is observed changing in the running game and restored, per key; `rust.group.permission` expires without core asking |
| 12 | **Events: option sources and the leases** (§9, **as corrected by §27**). ✅ **Built and walked 2026-09-24 on both rigs — as built and findings in §27.5–27.7.** 21 keys walked live, two `spawn.max_*` left out; two defects of its own fixed (the Oxide grant owner, `EventsEnabled` not applying); four found outside it and raised. [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised. **Plan of record in §27 (D73–D79)**: the target names the server, game convars only (vanilla Rust has no gather/craft/smelt rate), the weekend-VIP lease is a group-wide permission, an `EventsEnabled` switch off by default, seven-day holds, and no budget dimension until phase 13; protocol 8 | Module-Rust + 2 + docs | A leased value is observed changing in the running game and restored, per key; `rust.group.permission` expires without core asking |
| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it; a wipe reconciles the ledger instead of stranding it |
| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
@@ -4252,6 +4252,110 @@ on the Oxide rig against real core, with an event run authored in the admin UI:
6. **Carbon.** The same plugin gets a sidecar on the Carbon rig (`INSTALL_RIG.md`), and at least one
key from each family is walked there. If that cannot be done, this section says so.
### 27.5 As built, 2026-09-24
[Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr] on `feat/phase-12-leases`, into `edge`.
**Core is unchanged and `MODULE_API` did not move**, which D73 bought. Walked against real core on
`main` (a fresh `rustp12` database) with this module installed, on **both** rigs.
**The catalogue as shipped** is narrower than §27.2 planned, and each cut was made by the walk:
| Lease | Keys | Walked |
|---|---|---|
| `rust.decay.scale` | `decay.scale` | a lone twig foundation with `decay.tick` lowered on the rig lost 0.081 health per 32 s at 1, **0.404 at a lease of 5**, and 0.081 again after release |
| `rust.population` | **18** animal and vehicle populations | every one: the game's own target in `spawn.report` moved and came back (bear 18 → 45 → 18, and so on) |
| `rust.spawn.scalar` | `spawn.min_rate`, `spawn.min_density` only | `spawn.scalars`: the population rate 0.5 → 2 → 0.5, the density 0.5 → 1.5 → 0.5 |
| `rust.group.permission` | any registered permission on any group | a synthetic player in `default` passed `UserHasPermission` while the lease was held and failed it after |
- **The two `spawn.max_*` scalars are not lent.** The game interpolates between min and max by how
full the server is, so on an empty rig the effective values are the minimums and the maximums do
nothing that can be seen. §9's rule is that a key which cannot be seen doing something is not
advertised. A walk with players online is what would add them.
- **Every population is per square kilometre, vehicles included.** The game's own help text says
vehicles are "active on the server", but on the 9 km² rig a value of 3 is a target of 27.
**What the walk proved, against §27.4:**
1. **Each key's effect, observed**, as in the table. The first run held all four families through
core and tore them down at the end: every ledger row `reverted`, every value back.
2. **The game's own deadline, with the website gone.** A run took a 3-minute hold on a group
permission and on `decay.scale`, then core was **stopped**. At the deadline the plugin restored both
and emitted two `lease.expired` frames, which the sidecar stored. When core came back, its boot
reconcile asked about both holds, was told `held: false`, and marked both rows `orphaned` with the
module's reason.
3. **Drift.** `decay.scale 3` typed at the console during a hold. At teardown the row read `drifted`,
with *"the value is now "3" rather than what this run applied, so it was left alone"*, and the
world was left at 3.
4. **Reload and restart.** A plugin reload in the middle of three holds kept all three, deadlines
intact (`leases: 3 held`). A **server restart** in the middle of a convar hold and a permission
hold logged `leases: 1 held, 1 ended by a restart`: `decay.scale` came back at 1 and the permission
stayed granted. At teardown the permission was given back and the convar release found it
`already` restored. Both rows `reverted`.
5. **The switch.** With `EventsEnabled` off, an apply was refused with the switch named. It was
turned on **from the site's own config editor**, which is how an operator would do it (below).
6. **Carbon.** The same plugin file, with a sidecar installed in the Carbon rig's container the way
`INSTALL_RIG.md` describes. All 21 keys resolved there and none was `Saved`. Through core: bear
target 54, `decay.scale` 5 (0.47 per 32 s), `spawn.min_rate` 2, and a group permission
(`adminmodule.greet`, which Carbon registers itself) granted and then given back. All rows
`reverted`.
The permission mirror's side of §27.3 was walked too. A `perm.sync` sent during a hold listed the
pair under `leased` and not under `foreign`, and the site's wish was stored on the hold. At teardown
the pair stayed granted (`already true`), because the site had asked for it.
### 27.6 What the walk found that the plan did not say
**Two defects in this phase's own code, both fixed before merge:**
- **Every group-permission grant was a silent no-op.** `GrantGroupPermission(group, perm, this)`:
given an owner, Oxide first asks `PermissionExists(perm, owner)`, meaning *did this plugin register
the name*, and returns without a word when it did not (confirmed in the decompiled
`Oxide.Core.Libraries.Permission`). A lease only ever borrows another plugin's permission. The
plugin's read-back caught it (*"the store did not grant …"*), and the owner is now `null`.
- **`EventsEnabled` did not take effect when an operator changed it.** The site's editor saved the
file and answered `changed: true`, and the plugin went on refusing. It never re-reads its own
config, because it never reloads itself (§21.3). It is the first bridge setting an operator is
*meant* to change from the site, so a write to the bridge's own file now re-reads that one key.
The report says so: *"EventsEnabled took effect at once; the bridge's other settings apply on its
next reload."*
**Four findings outside this phase, raised and NOT fixed**:
- **The phase-7 permission mirror has the same owner bug.** `EnsureGroupPermission` and
`EnsureGrant` both call Oxide with `this` and count the grant without reading it back. On Oxide,
then, every site-authored grant of another plugin's permission has probably never landed, while the
report counted it as applied. It stayed hidden because every earlier walk used a permission that was
`unresolved` (Kits was not installed) and so never reached the call. The fix is one token in two
places plus a read-back. It needs a decision because it changes what the site has been told since
phase 7.
- **Core's cleanup restores `orphaned` lease rows, and that can overwrite a later run's world.** Run
2's rows were orphaned at core's boot reconcile, and run 2 was cancelled. Cleanup then tried to
move them back to `reverting`. That collided with run 3's live row for the same target on
`uq_evres_target` and failed on every sweep (13 times). Once run 3 released, the stale `restore()`
went through and **revoked the permission run 3 had deliberately left granted**. A lease's
compare-and-set cannot catch this when the later holder applied the same value. For a spawn,
reverting something that is gone is harmless. For a value, "no longer in force" plus a later
restore is a write of an old baseline over the present.
- **`core.lease` drops the module's `retry: false`.** Both of its failure returns rebuild the envelope
as `{ ok: false, error }`, so an `events-disabled` or `unresolved` refusal is retried three times.
The Carbon walk's step 27 did exactly that. Harmless, but it is noise and it makes
`retry: false` unreachable in a lease callable.
- **`core.lease` runs under the default 10 s with two module calls inside it.** The module fits by
giving lease calls a 4.5 s timeout (asserted in a test), which is below the sidecar's 10 s reply
timeout. A `budgetMs` on `core.lease` would remove the squeeze. §27.1 asked what a late-landing
retry does; the module's release-after-timeout (§14.7) covers the world side, and the question
stands for core.
### 27.7 What is not proven here
- **`spawn.max_rate` / `spawn.max_density`**, for want of players. They are left out, not guessed.
- **A real player seeing a kit unlock.** Kits is not installed on either rig and there is no second
client. The permission was observed through `UserHasPermission`, which is what Kits calls.
- **A crash, as opposed to a restart**, for the holds file. The same load path runs either way.
- **Walk 3's permission outcome as first observed.** It came out `false`. Walk 4 then showed the
intended behaviour (`already true`), and the cause turned out to be the core cleanup finding above,
not the plugin.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app

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.