From aa148adfc6fed4b965ecb9326005256a1cadfd94 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:50:24 -0500 Subject: [PATCH] =?UTF-8?q?docs(rust):=20phase=2010=20plan=20of=20record?= =?UTF-8?q?=20(PLAN.md=20=C2=A725,=20D59-D68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notifications and engagement phase, as decided by the org lead on 2026-09-23: the raid alert goes to everyone authorised on the tool cupboard, covers doors and external walls, never names the raider, and fires online or offline with the choice left to a rule condition. Nothing reaches the game this phase, which amends R7. Also corrects §10 where core's contract disagreed with it (there is no `self` ceiling). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- modules/rust/PLAN.md | 179 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 0444422..f08ec22 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -326,6 +326,10 @@ that this phase lives or dies on: And the emit discipline: **emit on the transition, not on the poll.** Core's cooldown would hide a module that emitted "the server is still up" as news. +> **Amended 2026-09-23 by D62 (§25.0).** The announce leg moves to phase 13 beside `rust.announce`, +> which needs the same in-game chat verb; the post hook is dropped until something in game can hold a +> post; PopupNotifications moves to phase 17. The other four registrations ship in phase 10. + ### R8 — multi-server from the start **Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs @@ -992,7 +996,7 @@ Each phase ends with its findings written down, as every workstream here does. | 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `/x.json` is found and reloads the right plugin | | 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app | | 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). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else | +| 10 | **Notifications and engagement** (R7). 🚧 **Plan of record in §25 (D59–D68), 2026-09-23.** 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). Inbox and notification preferences for Rust triggers | Android-app | 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 | | 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 | @@ -1257,6 +1261,10 @@ which is exactly why R16 could change what it grants without touching anything o ## 10. The engagement catalogue — what Rust can expose +> **Corrected by §25 (2026-09-23).** The table below is the catalogue as first planned. The one that +> shipped is §25.2: `self` is not a ceiling core has, `rust.clan.member.added` duplicates core's own +> Team trigger, `rust.kit.entitled` waits for phase 13, and the two player audiences were one set. + Added 2026-09-15, answering "check the default alerts Rust can expose". R7 settled that the set ships; this is what goes in it. @@ -3604,6 +3612,175 @@ alone. - `ListTopClans` returns a pooled list that the plugin does not return to the pool. The GC collects it; it is noted here so nobody "fixes" it into a double free. +## 25. Phase 10 — notifications and engagement (plan of record, 2026-09-23) + +R7's phase: the module says what happened in a Rust server in terms core's engagement engine can +act on — **triggers** (what an event is and the widest audience it may ever have), **audiences** +(named sets of people over this module's data), **seeds** (the bodies and the disabled rules that +use them) and **push streams** (the facet that lets a rule reach a phone). Nothing here decides who +is told: the module says what happened and who it is *about*, and core applies the rule, the +ceiling, the preference, the suppression list and the verification gate. + +Three repositories and this document. The criterion — *the offline raid alert reaches the player +whose base it was, and nobody else* — could not be met on the wire as it stood, which is why +[Rust-Plugins][rp] and [Rust-Link][rl] are in a phase the table listed as `Module-Rust + docs` +(§25.1). All three into `edge`. + +### 25.0 The decisions this phase needed + +All from the org lead, 2026-09-23. + +| # | Decision | +|---|---| +| **D59** | **"The player whose base it was" means everyone authorised on the building's tool cupboard.** Each linked one is sent their own alert — one emit per person, `ownerUserId` set, so the ceiling stays `owner` and "nobody else" holds per emit. The block's placer (`OwnerID`) is not the base's owner in any sense a Rust player would recognise: a base several clan members built alerts only whoever placed that wall, and a block placed by someone since kicked alerts *them*. An attacker who is authorised on the cupboard (a self-demolish, a teammate) raises no alert. | +| **D60** | **A raid is the destruction of a building block, a door, an external wall or gate, or the tool cupboard itself.** Today's emitter sees building blocks only, and a door is not one (§25.1), so the most common raid — two charges on a door — alerted nobody. | +| **D61** | **The alert fires whether or not the owner is online, and carries `ownerOnline`.** The seeded rule narrows with the condition `ownerOnline is false`, so "offline raid alert" is a rule an operator can widen by editing a condition rather than a code change. `ownerOnline` is the *recipient's* own state, since each emit has one recipient (D59). | +| **D62** | **Nothing reaches the game in this phase — which amends R7.** No announce leg and no post hook: R7 put both in v1, but the leg needs a chat verb the plugin does not have, and the hook would have nothing on Rust to mirror a post *into* (`module-uo` registers none either). The announce leg moves to phase 13, beside `rust.announce`, which needs the same verb; the post hook is dropped until something in game can hold a post; PopupNotifications moves to phase 17's optional tier. | +| **D63** | **A replayed event notifies only while it is still news, and the bound is per family.** After an outage the cursor (D9) replays hours of frames. A **broadcast** (server online/offline, wipe, leaderboard) older than **15 minutes** notifies nobody — "the server is up" is false three hours later. A **personal or staff** event (raid, clan, moderation) is kept for **24 hours** — "your base was raided at 03:10" is still true and still wanted. Every emit carries its true `occurredAt`, and a `dedupeKey` built from the server and the sidecar's own event id, so a replay of the same batch is a no-op in core's outbox. | +| **D64** | **Four changes to §10's catalogue.** `rust.clan.member.added` is **dropped** — core already fires `team.member.joined` (and `team.leadership.changed`) for our clans through phase 9's Team sync, so a second trigger double-notifies. `rust.kit.entitled` is **deferred to phase 13**, where the grant that emits it lands. `rust.login.denied` is **a deferred read** (§16.5): an attempt with no approval within 60 seconds, staff ceiling, **no address in the payload**. `rust.leaderboard.topped` is **the kills leader of the current wipe changing to somebody new**, never on first sight, once per server per leader. | +| **D65** | **Three families get a push-stream facet: the raid alert, server online/offline, and wipe started.** Core delivers engagement push only to devices subscribed to a stream whose id *is* the trigger id (§25.1), so a trigger without the facet can never reach a phone however its rule is set. Clan and moderation stay email and in-app. | +| **D66** | **The raid alert never names the raider.** The payload carries what was destroyed, the grid and the time. A raider's identity is gameplay intelligence the game itself does not hand the victim, and the org lead's lowest-blast-area rule (§23) applies: a variable that is not declared cannot be interpolated by any template an operator writes. | +| **D67** | **A structure with no tool cupboard alerts nobody.** No placer fallback: a cupboard-less structure is usually decaying or abandoned, and D59's whole point is that the placer is not the owner. | +| **D68** | **"Online" means the plugin is connected now, and that fixes the public page too.** Folded into this phase (§25.3). | + +### 25.1 Facts the phase rests on + +**From the game** (`Assembly-CSharp.dll` off the rig, decompiled — the build phase 9 pulled): + +- **A door is not a `BuildingBlock`.** `Door : AnimatedBuildingBlock : StabilityEntity`, a sibling of + `BuildingBlock : StabilityEntity`, so `entity as BuildingBlock` — the protocol-2 emitter's filter — + has never seen a door. External walls are `SimpleBuildingBlock : StabilityEntity`; external gates + are `Door`s. The cupboard is `BuildingPrivlidge : StorageContainer`. +- **`authorizedPlayers` is a `HashSet`** on current Rust (it was a `List` in + older builds, which is what most public raid-alert plugins still read). **`recentGroupMembers` is + not authorisation**: it counts code-lock users toward group upkeep and prunes on a timer, and it + must not be read as "lives here". +- **`DecayEntity.GetBuildingPrivilege()`** resolves through the entity's building + (`GetDominatingBuildingPrivilege`). An entity with no building falls back to `BaseEntity`'s, which + is a `Vis.Entities` sphere query on the main thread. That is acceptable at the rate structures are + destroyed — tens per raid, not per frame — and it is the reason the lookup happens only after the + cheap filters (a real player did it; the entity is one of D60's four). +- **`OnEntityDeath` runs before the entity is destroyed**, so a cupboard being destroyed can still + report its own authorisation list. + +**From core** (`website`, `main`): + +- **There is no `self` ceiling.** The lattice is `everyone`, `authenticated`, `subscribers`, + `members`, `staff`, `admin`, `owner`. §10 gave `rust.player.linked` and `rust.kit.entitled` a + `self` ceiling core would refuse at boot; `owner` with the user as `ownerUserId` is the value that + exists and means the same thing here. +- **Engagement push reaches a device only through a stream of the same id.** `pushChannel.deliver` + calls `publishToUsers(row.trigger_id, …)`, which selects endpoints subscribed to *that stream*. + Hence D65. +- **The cooldown is per (rule, user, subject, channel).** The raid alert's subject is therefore the + building (its cupboard's id): one alert per base per cooldown, however many walls fall. +- **`dedupeKey` is UNIQUE in the outbox**, so an emit replayed with the same key is a no-op. Core does + **not** bound a stale `occurredAt`; that is D63, on this side. +- **Core already notifies Team members on `team.member.joined` and `team.leadership.changed`.** Hence + D64's first change. +- **`MODULE_API.md` says a module ships its own `engagement-triggers.json`**; core never reads it. + It is a freeze whose diff is the review signal, like `routes.manifest.json`. `module-uo` ships + none. This module ships one, gated in CI the way `routeManifest` is (line endings normalised — + see the engagement workstream's CRLF lesson), and phase 19 carries the gap back to the kit. + +**From this module:** + +- **The sidecar keeps its last `server.hello` board after the game disconnects**, and the refresh + reads only `/boards`. So a game that hangs, or whose bridge plugin is unloaded, while the sidecar + stays up has read as *online* on the public page since phase 4. In the in-container deployment + (§22, R20) a crash takes the sidecar with it and the page is right; a hang or an unload is not. + `/health` already answers "is the plugin connected" and nothing asked it. D68. + +### 25.2 The catalogue as built + +**Triggers.** Every one declares `subjectKey`, and every variable an `example`. + +| Trigger | Source | `ceiling` / default audience | Subject | Push facet | +|---|---|---|---|---| +| `rust.base.destroyed` | `entity.destroyed` (protocol 7), one emit per linked authorised user | `owner` / `owner` | `building` | yes — personal, needs a linked account | +| `rust.wipe.started` | `server.wipe` | `everyone` / `subscribers` | `serverId` | yes | +| `rust.server.online` | the refresh, transition to connected (D68) | `everyone` / `subscribers` | `serverId` | yes | +| `rust.server.offline` | the refresh, transition away from connected | `everyone` / `subscribers` | `serverId` | yes | +| `rust.leaderboard.topped` | the wipe's kills leader changes (D64) | `everyone` / `subscribers` | `serverId` | — | +| `rust.player.linked` | the link confirm route (R1) | `owner` / `owner` | `steamId` | — | +| `rust.clan.member.left` / `.kicked` | `clan.member.left` / `.kicked` | `members` / `members` | `clan` | — | +| `rust.clan.disbanded` | `clan.disbanded` | `members` / `members` | `clan` | — | +| `rust.player.reported` | `player.reported` | `staff` / `staff` | `steamId` (the target) | — | +| `rust.player.banned` / `.unbanned` | `player.banned` / `.unbanned` | `staff` / `staff` | `steamId` | — | +| `rust.login.denied` | a `player.login.attempt` with no `player.approved` within 60 s (D64) | `staff` / `staff` | `steamId` | — | + +A clan trigger's recipients are the clan's linked members at the moment of the event, carried as +`recipientUserIds` (the mechanism `module-uo` and core's Team fan-out use), because "the clan this +event was about" differs every firing and cannot be a saved audience. For a disband that is the +roster *before* the disband is applied. + +**No variable carries an IP address, a Steam id other than the subject's own, or — for the raid +alert — the attacker (D66).** A ban's `reason` and a report's text are staff-ceilinged. + +**Audiences** — user ids and nothing else; one that fails resolves to nobody (§10, §11.5). + +| Audience | Resolves to | `ceiling` | +|---|---|---| +| `rust.clan.members` | a clan's linked members (param: the clan's `externalId`) | `members` | +| `rust.server.players` | linked accounts ever seen on a server (param: `serverId`) | `authenticated` | +| `rust.wipe.participants` | linked accounts seen on a server's **current** wipe (param: `serverId`) | `authenticated` | + +§10 described the second and third as the same set ("seen this wipe" and "played the current wipe"); +they are separated as all-time against this wipe, which is the distinction an operator can use. + +**Seeds.** Every rule disabled (core enforces it). One **group key per family** — `raid-v1`, +`wipe-v1`, `server-v1`, `leaderboard-v1`, `account-v1`, `clans-v1`, `moderation-v1` — so a rule +added to a family later can take a new key without the others. Bespoke bodies only where the message +has something to say that core's structural projection cannot: **the raid alert** and **the wipe**, +email and in-app each. Everything else points at core's `notify.event` / `inapp.event`. + +The raid rule: audience `owner`, `ownerOnline is false`, email + in-app + push, cooldown 30 minutes +per building. The offline rule: `delay_seconds` 300 with `cancel_on: ['rust.server.online']`, so a +plugin reload or a quick restart mails nobody. + +### 25.3 What the plan builds + +**The plugin (protocol 7).** `entity.destroyed` widens to D60's four entity kinds and gains +`buildingId` (the cupboard's net id) and `authorized` — each `{ steamId, online }`, **bounded at 64** +with `authorizedTruncated` when cut — both absent when there is no cupboard (D67). `ownerId`, `prefab`, +`grid` and the attacker fields stay as they were: the staff feed still reads them, and the module +decides what an alert may carry. `overlay.toml`'s protocol moves to 7. + +**The sidecar.** `PROTOCOL_VERSION` becomes 7. The frame is stored and served as it arrives (§8.1), +so nothing else changes. + +**The module.** +- **`server/engagement/`** — `triggers.js`, `audiences.js`, `streams.js`, `seeds.js` (the + declarations, one file each) and `emit.js`, the fan-out: one function per trigger, each wrapped so + a bad frame cannot stop ingest, each applying D63's bound before it emits. Registered from + `index.js`, which drops the comment calling these "deliberately absent". +- **Ingest** calls the fan-out after an event is written, inside the same per-item guard. +- **The refresh** asks `/health` beside `/boards` and records `connected`; `online` becomes + "the plugin is connected now" (D68). Transitions are tracked in memory, and a first sight is never + a transition, so a website restart announces nothing. +- **The leaderboard** leader is read after each ingest tick from the wipe's stats, with the same + first-sight rule. +- **The login-denied sweep** runs on the prune cadence as a query over `rust_events` — attempts + older than 60 s and younger than D63's 24 h with no approval for the same Steam id after them — + keyed on the attempt's event id. A query rather than an in-memory timer, so a restart loses + nothing and re-running it is a no-op. +- **`engagement-triggers.json`**, generated from the registrations and checked in CI. + +**The docs.** This section as built, `rust-link/PROTOCOL.md` §13 (protocol 7), R7's amendment, §10's +corrections, and the phase row. + +### 25.4 Done when + +The row's criterion: **the offline raid alert reaches the player whose base it was, and nobody +else.** Walked on the Oxide rig with a cupboard authorising two linked accounts, a third linked +account that is not authorised, and a raid made by a fourth. Both authorised accounts get one alert +each. The third gets nothing, and so does the raider. Then the same plugin file runs on Carbon. + +The rig has one Steam account, not four (§12.5), so the walk uses phase 9's method: a rig-only +helper that authorises synthetic Steam ids on a real cupboard and destroys a real door with a +spawned, non-NPC player as the initiator, so the real `OnEntityDeath` path runs. If the helper cannot +make the hook see a real player, that is written down as a walk still to run, not claimed. + --- [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app