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:
@@ -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
|
||||
|
||||
@@ -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