docs(engagement): the in-app channel on Android, as built (Phase 8)

Records Phase 8 and answers §7.1 Q8, leaving Q9 as the only open question.

ENGAGEMENT.md gains the phase's as-built section: the four decisions settled
first, the relative-url finding the live rig caught (Phase 7's contract is
relative-only, and the client half of that contract was never written down), the
ref-not-stream routing rule, the (base URL, user id) snapshot scoping that is the
actual security property, and the on-device walk. Its Status paragraph was six
phases stale and now names every phase that has landed.

android/PLAN.md §7's "no offline caching, no Room in v1" decision STANDS and now
names its one exception, with the limit it comes with: the snapshot serves a
running app, not a cold start, because the shell gates the whole app on loading
the site's appearance. Widening Phase 8 into the shell's startup model is the
org lead's call, so it is written up rather than done. §11 gains the inbox and
the per-channel screen as built.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-31 09:28:07 -05:00
parent feeb2cac11
commit b1851ad8c1
2 changed files with 154 additions and 20 deletions

View File

@@ -1,14 +1,18 @@
# The Engagement System — findings and plan
**Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a and 4b are built** (Phase 1: website#165 +
docs#178, with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b:
website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 + docs#182; Phase 4a:
website#170 + docs#183; Phase 4b: website#171 + docs#184); everything from Phase 5 on is still design.
The scope decisions below are settled; **seven of the eight questions in §7.1 are answered** — Q1, Q3,
Q5 and Q7 on 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's
namespace question), and **Q2 and Q4 on 2026-08-29 at the start of Phase 4**. Q1's answer added a
whole phase (**Phase 1b**, unique email addresses); Q4's answer and the phase's size split **Phase 4
into 4a and 4b**. **Q8** remains open and blocks Phase 8. Per CLAUDE.md § Conventions, no
**Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a, 4b, 5a, 5b, 6, 7 and 8 are built**
(Phase 1: website#165 + docs#178, with website#164 as its prerequisite; Phase 1a: website#166 +
docs#179; Phase 1b: website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 +
docs#182; Phase 4a: website#170 + docs#183; Phase 4b: website#171 + docs#184; Phase 5a: website#172 +
docs#185; Phase 5b: website#173 + docs#186 + runicgateway.com#22; Phase 6: website#174 + docs#187 +
runicgateway.com#23; Phase 7: website#175 + docs#188 + runicgateway.com#24; Phase 8: the Android
half, Android-app#42 + docs#190); everything from Phase 9 on is still design. The scope decisions
below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on
2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question),
**Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase
8**. Q1's answer added a whole phase (**Phase 1b**, unique email addresses); Q4's answer and the
phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter, added
2026-08-31 in docs#189 and recommended for Phase 11) is the only one still open. Per CLAUDE.md § Conventions, no
implementation starts without the org lead's approval of the phase it belongs to.
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
@@ -1335,7 +1339,7 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor |
| **6** Email channel + Teams migration ✅ | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing · `website/ENGAGEMENT.md` §4.2b + this phase as built · `BACKEND_DESIGN.md` route table and table inventory | **`runicgateway.com`**: `administration/teams.mdx` notification section. Landed with the phase |
| **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase |
| **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` |
| **8** In-app (Android) | `android/PLAN.md` §7 (the Room exception) + §11 (the inbox as built) · `website/ENGAGEMENT.md` this phase as built | `android-app/README.md`. Landed with the phase |
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
| **11** module-uo triggers | `modules/uo/API.md` · `modules/uo/README.md` | `module-uo/README.md` |
@@ -2774,7 +2778,7 @@ suppression list, which this channel has no equivalent of — there is no addres
---
### Phase 8 — The in-app channel (Android)
### Phase 8 — The in-app channel (Android)
Inbox screen, unread badge, and the tickle → pull → inbox path. App-store cadence, separate repo,
separate release.
@@ -2782,7 +2786,85 @@ separate release.
**Acceptance:** a tickle wakes the app, which pulls and shows the item; the inbox works offline from
cache; the existing preferences screen gains the per-channel toggles from Phase 3.
**Note:** `android-app`'s `pr-checks.yml` triggers only on PRs into `main`, so phase PRs onto a working
branch get **no CI** — the cutover PR is the first real run. Plan for that.
branch get **no CI** — the cutover PR is the first real run. Plan for that. **Resolved: §7.1 Q8 was
answered "fix it" and the trigger now runs on PRs into `edge` too, as this phase's first commit.**
#### As built — 8 (2026-08-31)
The four inbox routes Phase 7 shipped had no consumer on either platform; this is the Android one.
**Four decisions were settled by the org lead before any code**, and one of them resolves a
contradiction the phase carried from the day it was written.
1. **§7.1 Q8 — fix the CI trigger, as the first commit.** `pr-checks.yml` now runs on PRs into
`edge` as well as `main`. All nine M12 phase PRs merged with no CI at all and engagement Phase 8
was about to repeat it; the alternative was finding a Kotlin compile error inside the cutover
window with a whole workstream's diff to bisect. `sonarqube.yml` is untouched — it is a
push-on-`main` analysis, not a PR gate, so no phase PR was ever expected to run it.
2. **The offline snapshot is a JSON blob, not Room.** This phase's acceptance line ("the inbox works
offline from cache") **contradicted a decision already recorded in `docs/android/PLAN.md` §7**:
*"Offline caching is not a v1 requirement (decided)… does not ship a Room cache in v1. No `Room`
dependency in the initial build."* §7's decision stands and this is its one named exception. An
inbox is a short, read-only, newest-first list with a server-side cursor and no joins, so what
"works offline" needs is the newest page and the badge — one blob in the DataStore the push code
already uses, capped at the server's own default page size.
3. **The drawer's "Notifications" is the inbox; the settings are behind its gear.** Exactly the
arrangement Phase 7 shipped on the web (the bare path is the content, `…/settings` the
preferences), and what a person means when they tap the word.
4. **The settings screen moved onto `/notifications/channels`**, rendering a control per channel that
applies to each id and per mode that channel accepts.
**The one thing the app must do that no plan said: resolve the item's `url`.** Phase 7's acceptance
specifies `url` is **relative-only** and validates it as such — right for a browser already on the
site, and a dead link on a phone. The live rig caught it: every item came back as
`/guilds/the-silver-anvil/forum/403`, and the app's first cut only opened `http(s)`-prefixed strings,
so **every link in the inbox did nothing**. It now resolves against the configured base with OkHttp's
`HttpUrl.resolve`, which absolutises the path *and* returns null for anything that would not end up
http(s) — so a `javascript:` or `intent:` url in a notification body opens nothing at all. The
relative-only contract did not change; the client half of it was simply never written down.
**Three further things the build settled:**
- **The tickle's `ref` is what routes it, not its stream.** An engagement rule's tickle carries the
TRIGGER id as `stream` (§7.2's one namespace), and the app's shipped `forStream` map knows only the
eight 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 route it has always had. The ref is not decoded past that prefix and is never rendered: it is a
hint that a row exists, and the contract stays wake-and-pull, exactly as `pushChannel.js` says.
- **The snapshot is scoped to (base URL, user id), and that is the security property** — not the
clear-on-logout beside the push deregistration. A cache is only ever handed back to the pair that
wrote it, so the teardown paths that never reach a logout at all (a dead refresh token, a server
switch) cannot surface one person's notifications under another's session.
- **Reads are optimistic and are not rolled back.** The row flips locally, the server's post-write
`unread` replaces the local guess, and a failure is left alone: un-reading a row under someone's
finger looks like a bug, and the next refresh corrects it. A local read also rewrites the snapshot,
without which going offline right after reading everything would bring the badge back on the next
cold open.
**Verified on the live rig** (throwaway emulator + the real server on `edge` + real rows emitted
through `ctx.events.emit` → engine → outbox → `inappChannel`, never hand-written):
- the drawer badge showed **3**, the inbox listed the three items newest-first with unread dots and
local-zone timestamps, and a tap marked one read (3 → 2, server-side row updated);
- tapping an item opened its resolved link in a Custom Tab at the configured shard;
- one email chip wrote **exactly one row** — `(35, news.post, email, instant)` — and disturbed nothing
else, which is the sparse PUT's whole point;
- the push controls were **absent**, correctly, because the rig's shard advertises no relay, with the
reason in a note beside the list rather than replacing the screen (email and on-site preferences are
still worth setting on a shard with no push);
- with the network cut, the inbox rendered all five items from the snapshot under **"Offline — showing
what was saved on this device."**, unread count and read flags intact;
- a simulated tapped tickle (`STREAM=team.forum.post`, `REF=notification:5` — the exact extras
`PushNotifier` builds) landed on the inbox rather than Home.
**One finding this phase did NOT fix, deliberately.** *Offline works in a running app, not on a cold
start* — and the reason is the app shell, not the inbox. `MainActivity` gates the whole of `RunicApp`
on loading the site's appearance (M12), so an offline launch shows "Can't reach the site / Retry" and
never reaches the drawer at all. Widening this phase into the shell's startup model is the kind of
out-of-scope blocker that is a reason to ask, not a licence to widen the PR, so it is written up here
for the org lead. The acceptance line holds for the case the cache exists to serve — the app you were
just using, on a train — and fails for the case where the app was killed first.
**Still later phases':** nothing of this channel. The app now consumes every route Phase 7 built.
---
@@ -3033,11 +3115,12 @@ day it ships.
Composition must **narrow, never widen** — the segment takes the tightest ceiling it contains and is
still checked against the trigger's G24 ceiling. §5.1a is the design; Phase 2 owns the surface,
Phase 4 the composition UI, Phase 11 `module-uo`'s first real audiences.
8. **Android CI on `edge`** (§6.0a). `android-app/.gitea/workflows/pr-checks.yml` triggers only on PRs
into `main`, so Phase 8 lands with zero CI and Phase 13 is its first real build — as happened to all
nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately?
Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a
Kotlin compile error during the cutover window.
8. **Android CI on `edge`** (§6.0a). **ANSWERED 2026-08-31 (org lead), at the start of Phase 8: fix
it, as the phase's first commit.** `pr-checks.yml` triggered only on PRs into `main`, so Phase 8
would have landed with zero CI and Phase 13 would have been its first real build — as happened to
all nine M12 phase PRs. `pull_request.branches` is now `[main, edge]`. **`sonarqube.yml` was
deliberately left alone**: it is a push-on-`main` analysis rather than a PR gate, so no phase PR
was ever expected to run it.
9. **Core's own `news.post` emitter — and the three other things a publish already fires.**
`config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here
emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a