docs(rust): phase 11 as built and walked — §26.4-26.6, M16

The raid alert reached an emulator through ntfy.sh 19 seconds after a
rig raid, titled from its inbox row; switched off in the app, the next
raid enqueued in-app only. Closes M7's open QA item (on-device delivery
against a live ntfy), and amends the M7 and engagement phase 8 passages
D69 and D70 made stale.

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 15:40:02 -05:00
parent 83e57e6f47
commit f1b8329540
2 changed files with 110 additions and 8 deletions

View File

@@ -229,7 +229,8 @@ Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` + parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The `:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
planned; an on-device delivery pass against a live ntfy is the one open QA item. planned; an on-device delivery pass against a live ntfy is the one open QA item. *(Closed by M16 on
2026-09-23: a raid alert delivered through `ntfy.sh` to an emulator, 19 seconds end to end.)*
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration **Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links. against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
@@ -313,7 +314,10 @@ Work items:
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream `GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked (`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
account — reuse the linked-accounts signal already fetched for M4's player surface account — reuse the linked-accounts signal already fetched for M4's player surface
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the (`PlayerShardRepository`), not a fresh source of truth. *(M16 amends both halves: the signal is now
asked of the site's own module by capability (`LinkedAccountRepository`), because on a Rust site
`PlayerShardRepository` answered no for everyone. It holds back only switching push **on**, and no
longer greys the whole row.)* Toggling to a non-empty set triggers the
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4). folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's 7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
@@ -324,7 +328,8 @@ Work items:
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means `label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
nothing needs decrypting to render the tap, and the target screen fetches fresh over the nothing needs decrypting to render the tap, and the target screen fetches fresh over the
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
enhancement, not v1.) enhancement, not v1.) *(M16 did it for `notification:<id>` refs: `PushContentResolver` pulls the
inbox row and titles the notification with it. Every other ref keeps the generic title.)*
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My 8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
Account), visible once signed in. Account), visible once signed in.
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API 9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
@@ -1528,7 +1533,7 @@ push, and Play (M6–M8) follow the designed app.
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
check. Branch `fix/rust-presence-visibility`. check. Branch `fix/rust-presence-visibility`.
17. **M16 — a Rust notification on a phone** (post-v1; planned 2026-09-23). `module-rust` phase 17. **M16 — a Rust notification on a phone** (post-v1; built 2026-09-23). `module-rust` phase
11, R10's leg C: the app half of phase 10's notifications. **Design of record: 11, R10's leg C: the app half of phase 10's notifications. **Design of record:
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §26**, with its four decisions (D69–D72). [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §26**, with its four decisions (D69–D72).
@@ -1542,14 +1547,38 @@ push, and Play (M6–M8) follow the designed app.
channel **off** is never gated, and only switching push **on** needs a link. channel **off** is never gated, and only switching push **on** needs a link.
- **A push is titled from the item it points at.** A `notification:<id>` ref is pulled over the - **A push is titled from the item it points at.** A `notification:<id>` ref is pulled over the
authenticated inbox API, and the relay still carries nothing. The per-stream title stays as the authenticated inbox API, and the relay still carries nothing. The per-stream title stays as the
fallback and as the lock-screen version. §11's "nothing is fetched to show a notification" fallback and as the lock-screen version. The M7 items that said nothing is
stops being true, and it gets rewritten when this lands. fetched to show a notification are amended in place.
- **`/player/rust` and `/rust/servers/<id>?tab=<tab>` open natively.** M15 deferred the first - **`/player/rust` and `/rust/servers/<id>?tab=<tab>` open natively.** M15 deferred the first
of these to exactly this phase. of these to exactly this phase.
It also closes **M7's open QA item**. On-device delivery against a live ntfy has never been It also closes **M7's open QA item**. On-device delivery against a live ntfy has never been
walked. It is walked here, on `ntfy.sh` (D72). walked. It is walked here, on `ntfy.sh` (D72).
#### As built (2026-09-23)
Branch `feat/rust-phase-11-notifications`. The app suite is **672 tests, 0 failures**, up from
657, with `lintDebug` and `assembleDebug` green. It was walked on the `s22_ultra` emulator
against real core on `main`, with the module installed, the protocol-7 sidecar inside the Oxide
rig's container, and the relay on `ntfy.sh`:
- **Linked (raidowner1):** the raid alert's push switched on, and the device registered
`https://ntfy.sh/<topic>`. A door raided with phase 10's rig helper reached the phone
**19 seconds** later as *"Your base is being raided — A door was destroyed in P16 on Oxide
rig."* The notification was `vis=PRIVATE`, with a public version carrying only the generic
title. Tapping it opened the inbox, and the row opened the Oxide rig's screen natively.
- **Switched off in the app:** push off, and the next raid enqueued in-app only. The inbox row
arrived and no system notification was posted. With nothing else on push, the device was
unregistered and the push service stopped.
- **Not linked (walkadmin):** push held back with the hint, while email and in-app stayed live.
In-app was switched off, stored as `off`, and switched back on.
- **D71:** a row linking `?tab=leaderboard` opened on the Leaderboard tab, and `/player/rust`
opened *My Rust account*. `?tab=clans` went to a Custom Tab.
**Not walked:** a UO site (no UO core was running; the path is the call it always was, and a test
pins it), and the lock-screen rendering (the emulator has no screen lock; the public version was
read back from `dumpsys notification`).
### Deferred (not a milestone) ### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is - **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
@@ -1772,7 +1801,9 @@ of push.
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
that a row exists, and the contract stays wake-and-pull. that a row exists, and the contract stays wake-and-pull. *(M16 decodes it once more, to the id,
and only to pull that row over the authenticated inbox API and title the notification with it. It is
still never rendered.)*
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off **The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire: `/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:

View File

@@ -997,7 +997,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 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 | | 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 | | 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 | | 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). Inbox and notification preferences for Rust triggers. **Plan of record in §26 (D69–D72)**: the link check asked of the site's own module, a tickle titled from the item it points at, two links that stop opening the browser, and the relay hop walked on `ntfy.sh` for the first time on any site | Android-app | A Rust notification arrives on a phone and can be switched off there | | 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: 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 | | 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 | | 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 |
@@ -3992,6 +3992,77 @@ sidecar behind it:
If the relay hop cannot be made on `ntfy.sh`, it is written down as a walk still to run, and the If the relay hop cannot be made on `ntfy.sh`, it is written down as a walk still to run, and the
rest is proven with the tickle injected by `adb`, the extras `PushNotifier` builds. rest is proven with the tickle injected by `adb`, the extras `PushNotifier` builds.
### 26.4 As built, 2026-09-23
**Android-app** (`feat/rust-phase-11-notifications`, into `edge`):
- **`LinkedAccountRepository`** asks `/player/rust/links` on a `rust` site and
`/player/shard/accounts` on a `shard` site. A host that has never answered gets both asked, and a
host that named neither module gets neither.
- **`pushNeedsLink` and `canSetMode`** replace `itemSelectable`. The row stays live, the hint names
the push control, and a held push switch that is already on can still be turned off.
- **`PushContentResolver`** pulls the first inbox page under a five-second timeout. It titles only
from a row whose id matches the ref **and** whose trigger matches the tickle's stream. The
notification is `VISIBILITY_PRIVATE`, with a public version carrying the generic title.
`PushService` launches each tickle in its own job.
- **`resolveWebPath`** learns `/player/rust`, and `tab` on a Rust server path. `RustTab` gained its
wire names, and `RUST_SERVER` an optional `tab` argument.
The suite is **672 tests, 0 failures**, up from 657, with `lintDebug` and `assembleDebug` green.
**Module-Rust and core: no change**, as planned. This is the fifth Rust phase in a row that core did
not need to change for.
**The walk.** Real core on `main` with this module installed, the protocol-7 sidecar inside the Oxide
rig's container, the `s22_ultra` emulator, and `NTFY_PUBLIC_URL`/`NTFY_BASE_URL`/`NTFY_ALLOWED_ORIGINS`
all set to `https://ntfy.sh` (D72).
1. **Linked (raidowner1):** the raid alert's push switch was live. Switching it on registered
`https://ntfy.sh/<topic>` as the device's endpoint and stored the subscription. With no link
(walkadmin), push was held with the hint, while email and in-app stayed live. In-app was switched
off, stored as `off`, and switched back on.
2. **Raid, then the phone:** `rgraid.door` on the live rig, attacker `…09`, authorising raidowner1's
Steam id. Core dispatched to **two** channels (in-app and push). **Nineteen seconds** after the
console command, the phone posted *"Your base is being raided — A door was destroyed in P16 on
Oxide rig."* It was `vis=PRIVATE`, with a public version. The tap opened the inbox, and the row
opened the Oxide rig's server screen natively.
3. **Switched off on the phone:** push off in the app. The server then held no subscription and no
device, since nothing else had push on. The next raid dispatched to **one** channel. The inbox
row arrived (*"A door was destroyed in R15"*), no system notification was posted, and the push
service had stopped.
4. **D71:** rows seeded with the module's own URLs. `?tab=leaderboard` opened on the Leaderboard tab,
`/player/rust` opened *My Rust account*, and `?tab=clans` went to a Custom Tab.
**The criterion is met: a Rust notification arrived on a phone and was switched off there.**
### 26.5 What the walk found
Nothing the suite had missed. That's the first leg of this workstream to say so, and the reason is
worth keeping: every defect this phase fixed was found **by reading** before any code was written
(§26.1). The UO-shaped link check was visible in one line of the view model. The generic title was
visible in `PushStreams`. The cancelled pull was a property of `collectLatest` that nobody had needed
to think about until a handler suspended.
Two things are worth writing down:
- **The first real relay hop on any site went cleanly.** M7 built the distributor in 2026-07 and
nobody had delivered a tickle through a live ntfy to a device since. It worked the first time,
including registration against core's endpoint validator and the unregister when the last push
preference went off.
- **Two channels, then one, is the proof that "off" was honoured by the server, not by the phone.**
The app never saw the second raid's push: core did not enqueue it. A phone that merely stopped
showing notifications would have looked the same on the screen and different in the log.
### 26.6 What is not proven here
- **A UO site, walked.** No UO core was running. The UO branch is the same call it always was, and
`aUoSiteStillAsksTheShardModuleAndNeverTheRustOne` pins it.
- **The lock screen, seen.** The emulator has no screen lock. The public version was read back from
`dumpsys notification`, not looked at.
- **A burst.** Each tickle now runs in its own job, which is what a burst needs, but the walk sent
one raid at a time.
- **A real raid by a real player**, for §25.7's reason: the rig has one Steam account.
--- ---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app