docs(rust): phase 10 as built — protocol 7 and the raid alert

PLAN.md §25.5-25.8: what shipped, the walk that met the criterion on the
Oxide rig, the four things the rig found (every generic notice never
said which server; attackerId null for a spawned player; a disband
roster gone before the disband was read; in-app `subscribers` being
everyone), and what is not proven. PROTOCOL.md §13 specifies protocol 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 13:41:01 -05:00
parent aa148adfc6
commit cb8e8853f6
2 changed files with 170 additions and 3 deletions

View File

@@ -996,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** (D47D58). 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). 🚧 **Plan of record in §25 (D59D68), 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 |
| 10 | **Notifications and engagement** (R7). **Built and walked 2026-09-23 — plan, as built and findings in §25 (D59D68).** 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). 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 |
@@ -3781,6 +3781,116 @@ helper that authorises synthetic Steam ids on a real cupboard and destroys a rea
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.
### 25.5 As built, 2026-09-23
**Rust-Plugins (protocol 7).** `entity.destroyed` covers the four structure kinds and carries
`structure`, `buildingId` and `authorized` (PROTOCOL.md §13). `clan.disbanded` carries `members`
(§25.6). `attackerId` comes from `userID` (§25.6). The `structures` tally still counts building
blocks only.
**Rust-Link.** `PROTOCOL_VERSION` 7, and no other change.
**Module-Rust.** `server/engagement/` holds five files:
- **The declarations:** `triggers.js`, `streams.js`, `audiences.js` and `seeds.js`. That is 13
triggers, 4 streams, 3 audiences, 4 bodies (raid and wipe, email and in-app) and 13 disabled rules
in 7 groups.
- **The fan-out:** `emit.js`.
It is wired in three places:
- ingest calls `onEvent` for each stored frame, **before** it is applied, and `checkLeader` after a
batch;
- the refresh calls `serverObserved`;
- a new one-minute `sweep` timer runs the login-denied query.
The link confirm route calls `linked` on a **new** link only.
The refresh asks `/health` beside `/boards` (D68). `putState` gained a `seen` flag, so a stale board
no longer moves `last_seen_at`. `engagement-triggers.json` is the committed freeze of all of it,
checked in CI by `check:engagement` with line endings normalised. The check was verified by breaking
it both ways: a changed label fails it; a CRLF-only change passes. 234 server tests and 41 client
tests are green.
**The walk.** It ran against real core on `main` (MODULE_API 1.10.0) with a fresh database, the
protocol-7 sidecar inside the Oxide rig's container, and a rig-only helper (`RgRaidRig.cs`, never
shipped). The helper builds a real cupboard and door sharing a building id, authorises synthetic
Steam ids on the cupboard, and kills the target with a spawned, non-NPC player as the initiator, so
the real `OnEntityDeath` path runs.
- **Core accepted the whole set at boot:** 13 triggers, 4 streams, 3 audiences, 4 bodies and 13
rules in 7 groups, every rule disabled.
- **The criterion.** A door was raided in a base whose cupboard authorised owner1 and owner2
(both linked) and one unlinked id. The bystander was linked and not authorised; the raider was
linked. owner1 and owner2 each got **one** in-app alert: "Your base is being raided — A door was
destroyed in S16 on Oxide rig." The bystander, the raider and the unlinked id got nothing. Email
and push stayed quiet, because both default off until a person opts in.
- **Self-demolish** (the attacker authorised on the cupboard): no alert.
- **The cupboard itself destroyed:** owner1 was alerted, "A tool cupboard was destroyed in R3".
- **A door with no cupboard (D67):** the frame arrived without `buildingId`/`authorized`; no alert.
- **D68.** `oxide.unload RunicGateway` with the sidecar still up: within one refresh the module
read the server offline, with 0 players and `last_seen` frozen. Loading the plugin back raised
`rust.server.online`. The pending `rust.server.offline` notices, delayed five minutes, were
**cancelled** by it (`cancel_on`), twice.
- **Carbon 2.0.259:** the same plugin file compiled and loaded at protocol 7. The helper's door and
cupboard raids dispatched `OnEntityDeath` to the bridge (`rg.hooks`), and the new cupboard
lookup ran without an exception.
### 25.6 What the rig found that the plan did not say
- **Every generic notice said "A server came online", never which one.** Core's structural
projection fills `title` and `intro` from a trigger's label and description when the payload has
none. On a multi-server site that is a notice nobody can act on. Core's rule is "the payload wins,
the projection fills gaps", so every trigger now declares `title` and `intro`, and the emitter
writes the sentence. The walk's next transition read "Oxide rig is online — Oxide rig is back up
and talking to the website." No test could have seen this: every test asserted the payload, and
the defect was in what core did with a payload that was right.
- **`attackerId` arrived `null`.** `UserIDString` is set only in `PlayerInit` (a connection), `Load`
(a sleeper) and for engine bots, so a player another plugin spawns has none. Every real raider
has one, so this is the rig's artefact. But D59's self-demolish exclusion rests on the field, so
the plugin now derives it from `userID`, which is right for every player.
- **A disband's roster can be gone before the disband is read.** `markGone` deletes the members,
and the `clans` board is re-sent seconds after the event, so the refresh can apply it before
ingest reaches the frame. After an outage it always does. `clan.disbanded` now carries the roster
(PROTOCOL.md §13.2): the game walks `Members` to drop each membership and never empties the list.
The fan-out runs before a frame is applied as well, for the left and kicked cases.
- **`subscribers` is everyone, for in-app.** In-app defaults to `instant`, and core's `subscribers`
counts every active user with no in-app preference row as subscribed. So the walk's
"server online" reached all five users, not only walkadmin, who had opted in. That is core's
semantics, and `module-uo`'s broadcasts inherit it too. It is recorded rather than worked around:
an operator enabling a seeded broadcast rule reaches every inbox.
- **The dedupe key is made from the event, not the sidecar's row id**, which refines D63's
wording. A sidecar whose database is replaced restarts its ids, and a key built on them would
swallow every new alert as a repeat of an old one.
### 25.7 What is not proven here
- **A real raid by a real player.** The rig has one Steam account (§12.5). The helper runs the real
hook with a spawned initiator, which is as close as a player-less rig gets.
- **The frame's contents on Carbon.** The Carbon rig has no sidecar in its container (phase 8 wired
only the Oxide one), so on Carbon the hook dispatch and the absence of an exception are proven,
but the frame was not read back. The code after the hook is game API, the same assembly on both
frameworks.
- **Email and push delivery.** Both default off. The walk proved the rule reaches the right people
in-app. Push needs a subscribed device, and phase 11 (Android leg C) is where a phone subscribes
to a Rust stream.
- **The clan, moderation, wipe, leaderboard and login-denied triggers on the rig.** The suite covers
each one's audience and the bounds on it. The walk exercised the raid alert (the criterion) and
online/offline (D68).
### 25.8 Smaller things worth keeping
- **A running sidecar binary cannot be overwritten** (the node answers `500`, "text file busy"). A
rename can replace it: upload as `.new`, move the old one aside, then move the new one in.
- **Every panel client call needs `Accept: application/json`.** Without it the panel answers `302` to
its login page, and a binary upload "succeeds" with the old file still in place.
- **The walk found a latent schema defect outside this phase** and did not fix it. It is raised with
the org lead separately: tables since phase 6 declare `DEFAULT CHARSET=utf8mb4` with no
collation, while the older ones inherit the database default, so the VARCHAR foreign keys only
form when the database's default collation happens to be `utf8mb4_uca1400_ai_ci`.
- A Python heredoc halved a backslash in a JavaScript string again; the Edit tool is the safe path
for anything with an apostrophe in it.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **6** as of first-party clans (§12) — declared in
The wire version is a single integer — **7** as of the raid frame (§13) — declared in
**four** places that must agree:
| Where | Repo |
@@ -337,6 +337,7 @@ arrives with the phase that needs it, and each is a version bump:
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
- ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
- ~~who lives in a raided base, for the raid alert (phase 10)~~ — **protocol 7, §13**
- leases, budgets and the event actions (phases 12-13)
- the map image over the asset-bridge shape (phase 14)
@@ -451,7 +452,7 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
| `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
| `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
| `entity.destroyed` | `OnEntityDeath` on owned building blocks **and doors, external walls and the cupboard from protocol 7 (§13)** | **staff** | ownerId, prefab, grid, attacker; from protocol 7 also `structure`, `buildingId`, `authorized` |
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
| `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** |
@@ -1196,3 +1197,59 @@ matches a call to a method by its argument types, so neither call reaches it.
the bridge's own `rg.hooks` count for `OnClanDisbanded` stayed at the one real disband, with nothing
logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the
game's.
## 13. Protocol 7 — the raid frame names who lives there
Added in phase 10 ([`PLAN.md`](../modules/rust/PLAN.md) §25). The raid alert goes to the people whose
base it was (D59), and protocol 2's `entity.destroyed` could not say who that is: it named the
block's **placer** (`ownerId`), which is not the base's owner in any sense a Rust player recognises,
and it fired only for `BuildingBlock` — which a door is not. **No new kind and no new route;** one
frame widens, and `clan.disbanded` gains its roster.
### 13.1 `entity.destroyed`, widened
It now fires, still only when a real (non-NPC) player did it and `OwnerID` is non-zero, for four
kinds of entity. The kind travels as `structure`:
| `structure` | Game type | Notes |
|---|---|---|
| `block` | `BuildingBlock` | as before; the only kind the `structures` tally counts, so that column keeps its meaning |
| `door` | `Door` (an `AnimatedBuildingBlock`, a *sibling* of `BuildingBlock`) | external gates are doors too |
| `wall` | `SimpleBuildingBlock` | external walls |
| `cupboard` | `BuildingPrivlidge` | the tool cupboard itself; `OnEntityDeath` runs before the kill, so it still reports its own list |
Two fields are added when the entity resolves to a cupboard (`DecayEntity.GetBuildingPrivilege()`,
which goes through the building, or the cupboard itself):
| Field | Meaning |
|---|---|
| `buildingId` | the cupboard's network id, as a string — the base's identity, and the raid alert's cooldown subject |
| `authorized` | `[{ steamId, online }]` from the cupboard's `authorizedPlayers`, **bounded at 64**; `authorizedTruncated: true` when cut |
**Both are ABSENT when there is no cupboard**, which is a different answer from an empty list, and
the website alerts nobody in that case (D67). `recentGroupMembers` — which also sits on the cupboard
— is **not** authorisation: it counts code-lock users toward group upkeep, and it is not sent.
`attackerId` is now derived from the player's `userID` rather than `UserIDString`, which the game
fills in only for a connected player, a loaded sleeper or an engine bot. The website skips the alert
when the attacker is on the cupboard (a self-demolish, a teammate), and a null would defeat that.
The class is unchanged: **staff**. A structure's grid is where somebody lives, and the frame now also
names who. It reaches a player only through the raid alert, which is ceilinged `owner` and sent one
person at a time.
### 13.2 `clan.disbanded` carries `members`
The Steam ids of the clan it ended. The website tells a disbanded clan's members, and by the time it
reads the frame the next `clans` board may already have removed the roster from its store — the board
is re-sent seconds after the event, and after an outage it is applied before the backlog. The game
deletes the clan and walks `Members` to drop each membership, but never empties the list, so it is
whole when the hook fires. Bounded by the clan's own member limit.
### 13.3 The sidecar
`PROTOCOL_VERSION` becomes 7 and nothing else changes: both frames are `event`s, stored and served as
they arrive (§8.1). The bump exists because a website that alerts on `authorized` must not pair with
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.