diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 817ef67..c4a18b1 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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 diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index 7bc40e3..49df05c 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -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.