docs(rust): phase 12 — the leases (PLAN.md §27, PROTOCOL.md §14) #267
@@ -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: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` 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 |
|
||||
@@ -1191,6 +1191,12 @@ its reasoning as the starting point rather than inventing a parallel set.
|
||||
> hours is every game."* It also rates Rust the **easier** case than UO, because Oxide's convars are
|
||||
> live by default where ServUO's are mostly cached at boot.
|
||||
|
||||
> **Corrected by phase 12 (§27, 2026-09-23).** Vanilla Rust has **no** gather, craft or smelt rate
|
||||
> convar, so those three leases have nothing to set and are out (D74). `rust.group.membership`
|
||||
> became the group-wide `rust.group.permission` (D75). Every lease's target names its server (D73).
|
||||
> And *"caps are per server"* holds only for a run an admin starts by hand: a scheduled run always
|
||||
> has the empty scope. The tables below are the plan as first written.
|
||||
|
||||
### Budgets — what core counts and bounds
|
||||
|
||||
| Dimension | Counts |
|
||||
@@ -4065,6 +4071,293 @@ Two things are worth writing down:
|
||||
|
||||
---
|
||||
|
||||
## 27. Phase 12 — the leases (plan of record, 2026-09-23)
|
||||
|
||||
The first phase where an event changes a Rust server. [kit][kit] ch. 5 orders it before the actions,
|
||||
and the reason still holds: a lease borrows a value that already exists, so its baseline costs
|
||||
nothing. It proves the whole command path (correlation, the deadline, the drift check, reconcile)
|
||||
before anything hands out loot.
|
||||
|
||||
Three repositories: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr], all into `edge`, as
|
||||
**protocol 8**. This document goes into `main`. **Core is not expected to change and
|
||||
`MODULE_API` does not move**. D73 is what makes that true.
|
||||
|
||||
**Three things §9 said turned out to be wrong**, and the facts below are what the phase is built on
|
||||
instead:
|
||||
|
||||
- The three rate leases it leads with have **no convar to set** in vanilla Rust.
|
||||
- `rust.group.membership` became a group-wide permission (D75).
|
||||
- *"Caps are per run, so a cap is per server"* describes a fan-out core does not perform: every
|
||||
scheduled run has the empty scope.
|
||||
|
||||
### 27.0 The decisions this phase needed
|
||||
|
||||
All from the org lead, 2026-09-23.
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| **D73** | **The target names the server.** Every Rust lease is *targeted*, and the target begins with the server id: `<serverId>` for a single value, and `<serverId>/<…>` for a family. The ledger's ref then becomes `rust.decay.scale#srv-a`, so two servers never collide and one server has exactly one holder. That is the two-events-one-target rule at the granularity Rust actually has. No core change and no `MODULE_API` bump are needed. An event across six servers is six steps. Rejected: changing `core.lease` to forward `run.scope` and fold it into the ref. That is a 1.11.0 bump, a website PR and a kit re-pin, and it still does nothing for a scheduled run, whose scope is always `''`. |
|
||||
| **D74** | **Game convars only.** The catalogue holds the convars vanilla Rust actually has: `decay.scale`, the `*.population` values and the `spawn.*` scalars, each verified live before it is advertised. **There is no gather, craft or smelt lease**, so §H's canonical *"double gather weekend"* cannot be expressed this phase. Rejected: multipliers owned by the plugin, implemented through `OnDispenserGather` and blueprint times. That makes the bridge a gameplay mod, and the value would exist only while our plugin is loaded. |
|
||||
| **D75** | **The "weekend VIP" lease is a group-wide permission.** `rust.group.permission`, a `bool` with the target `<serverId>/<group>/<permission>`. *"Group `default` holds `kits.vip` until Monday"* makes everybody VIP for the weekend, which is the event-shaped use. Rewarding a named winner stays in phase 13, where participants are known at run time and not at authoring time. Rejected: per-player membership (the target must be fixed when the step is written, so it only serves a player chosen in advance), and building both. |
|
||||
| **D76** | **Unattended event writes have their own switch on the plugin, off by default.** `EventsEnabled` goes in the plugin's config, editable from the site through R18. This is UO's `Bridge.EventsEnabled` rule: a scheduled change at 04:00 is a different consent from staff moderation or a permission sync. **It gates `lease.apply` only.** Listing and releasing always work, so switching events off never strands a hold. |
|
||||
| **D77** | **A lease may be held for up to seven days.** `maxDurationMs` is 604 800 000 on every Rust lease, and the plugin holds the same ceiling independently. It **refuses** a longer hold and never clamps it (link v6 §8.2's rule). A week-long event fits in one hold. The cost is accepted: if the site goes away, the world stays changed for up to a week before the game's own deadline restores it. |
|
||||
| **D78** | **Only the option sources this phase's leases read.** Those are the three targets' sources, and nothing else. Kits, prefabs, monuments and zones arrive with the verbs that consume them in phases 13 and 14, so no source ships without a reader. |
|
||||
| **D79** | **No budget dimension is declared this phase.** No lease spends one: `core.lease` deliberately carries no cost. A dimension with nothing to spend it would still show on the operator's cap screen as a dial that does nothing. Each of §9's four is declared beside the verb that costs it, in phase 13. |
|
||||
|
||||
### 27.1 Facts the phase rests on
|
||||
|
||||
**From the Oxide rig** (`rust-oxide`, 2026-09-23: `find` on the console, and `serverauto.cfg` read back):
|
||||
|
||||
- **No vanilla convar sets gather, craft or smelt rates.** `find gather` returns only
|
||||
`gamemodesoftcore.gather_rate`, which applies only in Softcore mode. `find craft` returns
|
||||
`craft.instant`, which is a `bool`. `find smelt` and `find oven` return nothing that is a rate.
|
||||
§9's `rust.rate.gather` / `.craft` / `.smelt` rows have nothing to set. `rust.time.night` is also
|
||||
unverified: `server.cycletime` exists, but its description was machine-generated (`(Generated)`),
|
||||
and a lease on day length is out until a walk shows what it actually does.
|
||||
- **What exists and is shaped like a lease:** `decay.scale` ("1.0 = normal, 0.0 = no decay"); about
|
||||
twenty-five `*.population` values (animals per km², and vehicles per server); and
|
||||
`spawn.min_rate` / `max_rate` / `min_density` / `max_density`.
|
||||
- **None of those is a `Saved` convar.** `serverauto.cfg` holds the `Saved` set (for example
|
||||
`decay.upgrade_*` and `env.redmoon`), and none of the candidates appears in it. **A restart is
|
||||
therefore a free restore** for every convar lease, as it is for UO's config lease (link v6 §8.3).
|
||||
The plugin enforces this rather than trusting it: it refuses to advertise any allowlisted convar
|
||||
whose `Command.Saved` is true, because a hold on one of those would be written to disk and survive
|
||||
the restart that was supposed to end it.
|
||||
- **A group permission is not memory-only.** Both frameworks persist their permission store, so a
|
||||
hold that is not restored survives a crash, a restart and a plugin reload. **This is the one lease
|
||||
the plugin must remember itself.**
|
||||
|
||||
**From core** (`website`, `main`):
|
||||
|
||||
- **`core.lease` hands a module's callables only `{ target }`** (`read`), `(value, until, { target })`
|
||||
(`apply`), `(baseline, { expected, target })` (`restore`) and `{ ref, target, payload }`
|
||||
(`inForce`). The run's `scope` reaches actions in `dispatch.js`'s envelope but never reaches a
|
||||
lease, and the ref is `<id>#<target>` with no scope in it. D73 is the answer.
|
||||
- **Every scheduled run has the empty scope** (`eventRunner.js`, org lead 2026-09-02): *"A fan-out
|
||||
across named scopes needs a registry of what a scope IS, which no phase owns yet."* Only an admin's
|
||||
own start route takes a scope. §9's *"six servers is six budgets"* therefore holds only for runs
|
||||
started by hand.
|
||||
- **`core.lease` declares no `budgetMs`, so it runs under the default 10 s**, and inside that its
|
||||
`perform` makes two module round trips (`read`, then `apply`). This module's sidecar client waits
|
||||
up to 12 s on each. So one slow read is enough for the dispatcher to classify the attempt as retry
|
||||
while `perform` is still running. **To be checked at build, not assumed:** what a retried
|
||||
`core.lease` does when its first attempt's reservation and apply land late. If that is a core
|
||||
defect, it is raised and not fixed here (it applies to UO equally). The module's own half is in
|
||||
its control: lease calls get a timeout short enough that `read` plus `apply` fit inside 10 s, and
|
||||
a test asserts the sum.
|
||||
|
||||
**From the bridge** (`Rust-Plugins`, `edge`):
|
||||
|
||||
- **The permission mirror (protocol 4) reports anything it did not author as `foreign`**, and the
|
||||
plugin emits `perm.drift` for writes it did not make itself. Unless it is taught otherwise, a
|
||||
lease-held group permission would be reported to the site as a human's console grant. See §27.3.
|
||||
|
||||
### 27.2 The catalogue
|
||||
|
||||
What the plan will declare. **Every key is verified live before it ships**, and a key that applies
|
||||
and reads back but changes nothing is dropped, not advertised (§9's rule).
|
||||
|
||||
| Lease | Type | Bounds | Target | Source |
|
||||
|---|---|---|---|---|
|
||||
| `rust.decay.scale` | `float` | 0 to 10 | `<serverId>` | `rust.options.servers` |
|
||||
| `rust.population` | `float` | 0 to 50 | `<serverId>/<convar>` from the population allowlist | `rust.options.populations` |
|
||||
| `rust.spawn.scalar` | `float` | 0 to 10 | `<serverId>/<convar>` from `spawn.{min,max}_{rate,density}` | `rust.options.spawnscalars` |
|
||||
| `rust.group.permission` | `bool` | none | `<serverId>/<group>/<permission>` | `rust.options.grouppermissions` (searchable) |
|
||||
|
||||
- **The bounds are enforced twice**, as UO's are. Core checks the declaration's `min`/`max` when an
|
||||
author saves, so a bad value is refused on a form and not by a step running unattended. The
|
||||
plugin's own per-key bounds are what hold when the website is wrong.
|
||||
- **Populations share one lease because they share a unit, not because they share a value.**
|
||||
`metaldetectorsource.population` (1200 by default) does not fit the 0 to 50 bound and is left
|
||||
out. So is any population the live walk cannot show taking effect.
|
||||
- **An option source's values are whole targets.** A target source cannot depend on another field
|
||||
of the same step, so `rust.options.populations` lists `srv-a/bear.population`, and not a server
|
||||
followed by a population. Each source resolves every configured server's live answer and returns
|
||||
`[]` for a server that does not answer, so one server being down never blanks the form (§9).
|
||||
- **`maxDurationMs` is seven days on all four** (D77).
|
||||
|
||||
### 27.3 What the plan builds
|
||||
|
||||
**Rust-Plugins**: protocol 8, and `overlay.toml`'s protocol advanced to match.
|
||||
|
||||
- **`lease.list`, `lease.apply` and `lease.release`**, as UO's (link v6 §8.2):
|
||||
- **`holdMs` is authoritative** and `untilMs` is for display.
|
||||
- **Values cross as text** and are compared parsed.
|
||||
- **A hold longer than seven days is refused.**
|
||||
- **Release is compare-and-set.** A current value that is not what the lease applied answers
|
||||
`lease.drifted` with that value, and the world is left alone.
|
||||
- Convars are resolved through `ConsoleSystem.Index.Server.Find`, from an **allowlist in the
|
||||
plugin**: the sidecar forwards and knows nothing. `Saved` keys are refused at load (§27.1).
|
||||
- **`EventsEnabled`**, a new config key that defaults to `false` (D76). It gates `lease.apply`
|
||||
alone, and its refusal says which switch to turn.
|
||||
- **The deadline lives on the game.** Each hold arms a timer. When the timer passes, the plugin
|
||||
restores the baseline (still compare-and-set) and emits `lease.expired` without being asked. The
|
||||
website is never needed for the fail-safe.
|
||||
- **A holds file under the framework's data directory** records each hold: baseline, applied value,
|
||||
deadline, and the `bootId` it was taken under. That directory is live state, which R18's editor
|
||||
never walks. What happens on load depends on how the plugin came to be loaded:
|
||||
- **Plugin reload, same boot** (a config save from the site, a framework update): convar holds
|
||||
are re-armed, because their values are still in the game's memory. A reload does not end an
|
||||
event.
|
||||
- **New boot**: convar holds are dropped, because the restart already restored them.
|
||||
Group-permission holds are re-armed, and any whose deadline passed while the server was down
|
||||
are restored at once.
|
||||
- **The permission mirror learns about leases.** A `(group, permission)` pair held by a lease is
|
||||
skipped by `perm.sync`'s diff while the hold is live. It is reported as `leased` and never as
|
||||
`foreign`, and the plugin's own lease writes raise no `perm.drift`.
|
||||
- **Carbon** needs nothing conditional. `ConsoleSystem` belongs to the game, and the permission
|
||||
calls are the ones R19 already proved.
|
||||
|
||||
**Rust-Link**: protocol 8. `GET /lease`, `POST /lease` and `POST /lease/release`, forwarded with
|
||||
`cmd` and `reqId` written over the caller's, as every protocol 4 and 5 route is. `lease.expired`
|
||||
travels on the feed. As with UO, the website maps it to nothing, because core learns what happened
|
||||
through `restore` and `inForce`.
|
||||
|
||||
**Module-Rust**:
|
||||
|
||||
- `registerEventLeases` with §27.2's four leases, and `registerEventOptionSources` with its four
|
||||
sources. **No `registerEventBudgets`** (D79).
|
||||
- Target parsing splits off the server id first. A target naming no configured server is refused
|
||||
`retry: false`, because the second attempt carries the same params.
|
||||
- `restore` maps `lease.drifted` to `{ ok: false, drifted: true, current }`, and maps a target that
|
||||
no longer exists (a group deleted mid-hold) to a successful release. `inForce` reads the plugin's
|
||||
holds, **never a comparison with `read()`** (MODULE_API §1.1).
|
||||
- A lease-specific client timeout, with the arithmetic from §27.1 under test.
|
||||
- No new route, and no swagger change expected.
|
||||
|
||||
**docs**: this section as built, the phase row, §9 corrected where §27.1 contradicts it, and
|
||||
`rust-link/PROTOCOL.md` §14 for protocol 8.
|
||||
|
||||
### 27.4 Done when
|
||||
|
||||
The row's criterion, amended by D74 and D75: **a leased value is observed changing in the running
|
||||
game and restored, per key; and `rust.group.permission` expires without core asking.** It is walked
|
||||
on the Oxide rig against real core, with an event run authored in the admin UI:
|
||||
|
||||
1. **Each key's effect is observed in the game, not only read back.**
|
||||
- A population: `spawn.report`'s target count for it moves.
|
||||
- `decay.scale`: `decay.debug` on a rig with `decay.tick` lowered shows the damage scaled.
|
||||
- A group permission: a player in the group passes `UserHasPermission`, and Kits' menu shows the
|
||||
kit unlocked.
|
||||
Each is then restored at teardown, and the ledger row reads `reverted`.
|
||||
2. **The game's own deadline.** The run is started, and core is **stopped** before the hold ends.
|
||||
The plugin restores the value at the deadline and emits `lease.expired`. On restart, core's
|
||||
reconcile sees `held: false`.
|
||||
3. **Drift.** A console change during a hold leaves the world alone at release, and the row reads
|
||||
`drifted` with the current value beside it.
|
||||
4. **Reload and restart.** A plugin reload mid-hold keeps the hold, and its deadline still fires. A
|
||||
server restart mid-hold releases a convar hold and keeps a group-permission hold.
|
||||
5. **The switch.** With `EventsEnabled` off, `apply` is refused with the reason, while a release of
|
||||
an existing hold still succeeds.
|
||||
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