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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user