docs(rust): phase 10 — notifications and engagement (PLAN.md §25, PROTOCOL.md §13) #265

Merged
whitlocktech merged 2 commits from docs/rust-phase-10 into main 2026-09-23 18:43:16 +00:00
Showing only changes of commit aa148adfc6 - Show all commits

View File

@@ -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 `<Mod>/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<ulong>`** on current Rust (it was a `List<PlayerNameID>` 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