docs(rust): phase 12 plan of record — the leases (PLAN.md §27, D73-D79)
The rig disproved three of §9's rows: vanilla Rust has no gather, craft or smelt rate convar. core.lease never tells a module which server, so the target names it. And every scheduled run has the empty scope, so "caps are per server" holds only for runs an admin starts by hand. 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: 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**). [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,189 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user