mcp: project-plan — petal-dispatch Phase 4 brief

This commit is contained in:
Lovebug MCP 2026-06-06 03:24:28 +00:00
parent 86a423fe60
commit 5a1575e442

View File

@ -0,0 +1,142 @@
---
created: '2026-06-06'
path: Sources/Dev
project: petal-dispatch-phase4-brief
tags:
- petal-dispatch
- lovebug
- phase-4
- android
- kotlin
- sse
- fastapi
- tailscale
- p4
- execution
type: project-plan
---
# petal-dispatch — Phase 4 — Native Android Client — Worker Brief
**Project:** petal-dispatch (p4) · **Thread:** 588 · **Repo:** `petal-power/petal-dispatch-android` (Gitea)
**Plan ref:** p4 Phase 4 · Satisfies FR-001 (mobile), FR-003, FR-004, FR-005, FR-006, FR-012 (Android surfacing), FR-007, SC-1, SC-3
**Structure:** Two sequential gated spawns (4a → 4b). Each lands a handoff to thread 588 before the next spawns. A human auth-boundary gate precedes 4a; a human install + on-device-verify gate sits between every build and the next spawn.
## Goal
A native Android client for petal-dispatch that reaches **parity with the live Phase 3 web client** — Chat (orchestrator round-trip via the store) and Threads (list, live-thread highlight, render-only tap-to-switch) — on the harder platform. Phase 3 shipped and is dogfooded in production at `https://dispatch.herbylab.dev/`; Android **re-implements proven surfaces against an already-settled backend contract**, it does not pioneer new ones. Web went first deliberately (p4 D-1) so this phase copies a working reference, not a paper spec.
**Phase 4 success bar:** From the phone, on the tailnet, Travis sends a message and gets a conversational orchestrator response, round-trip through the store (FR-005), rendered (SC-1: connect in ≤3 refreshes). Threads tab renders live thread state with ≤1-action focus switch (SC-3, render-only). **Orchestrator only — no worker spawn, no Tasks surface, no Activity feed this phase.**
The prior `local_851b4b30… "Android Kotlin chat app scaffold"` and the existing `petal-dispatch-android` v0 chat app are **reference-only, not a base.** Mine them for Compose / Material 3 / OkHttp patterns; ship fresh. This resolves the mild tension in p4 Phase 4's own text ("extend the existing chat screen" vs. "drop WebSocket-only") cleanly toward the SSE rewrite — the repo continues, the v0 WebSocket chat code is replaced and survives only in history. Standard worker discipline (zero-check, a-review per handoff) applies per env instructions.
## Locked Decisions
**Scope — lean parity (Travis call).** Chat + Threads only, matching the web client. **Out of scope this phase:** Activity tab (no in-app event feed — debug via the web client or DB), Tasks tab, worker-task/stall surfaces (FR-001 worker case, SC-7), optimistic render (Phase 5), notifications. Rationale: lean Phase 4 has no worker-spawn surface, so Android v1 is a true re-implementation of proven Phase 3 surfaces — no surface where Android is simultaneously the first client to exercise a behavior. The platform is the only new variable.
**Build approach — fresh project, scaffold as reference (Travis call).** New Compose project in the continuing `petal-dispatch-android` repo. Carry forward *patterns* (Compose structure, Material 3 theme, OkHttp transport choice), not the WebSocket chat screen. README updated to the v1 contract. Mirrors 3b discipline (`static/` was reference-only, shipped fresh).
**Scaffold inputs (locked — worker needs these at gradle-init):**
- **Application ID / package:** `dev.herbylab.petaldispatch`.
- **`minSdk` = 36, `targetSdk` = 36** (`compileSdk` 36). Phone is on **Android 16 (API 36)**; single-device, sideloaded, tailnet-only personal tool means no long-tail support pressure, so the floor is matched to the daily driver for the cleanest code (no compat shims, no `Build.VERSION` branching). *Build note: compiling against API 36 may require a recent Android Gradle Plugin; if a headless build throws "unknown compileSdk" or AGP-too-old, the fix is bump AGP, not lower the SDK — surface in 4a recon so it doesn't eat a build cycle.*
- **APK output path:** Lovebug builds to default Gradle output (`app/build/outputs/apk/debug/app-debug.apk`) **and** copies to a stable, timestamped known path — `~/petal-dispatch-apks/4a-<timestamp>.apk` / `~/petal-dispatch-apks/4b-<timestamp>.apk` — so handoff notes point to it cleanly.
- **Repo / branch:** work happens on a **feature branch** off `petal-dispatch-android` that will merge into the project; v0 lives on in history (no sibling-dir, no orphan branch). The fresh build replaces v0 chat code on that branch.
- **Branch + push policy:** Gitea-destined, **auto-push is fine** (standing model) — worker pushes the branch at sensible intervals, i.e. **when a significant feature lands**, not every commit and not held local.
**Auth — tailnet-only open (p4 D-14 / D-7, confirmed).** `OPEN_MODE=1`; **all client↔backend traffic over Tailscale**; no token, no header, no login wall. The Android client hits `https://dispatch.herbylab.dev/` over the tailnet and inherits the same `_check_auth` no-op posture the web client runs under. **Zero new auth code in Phase 4.** Trust boundary is the tailnet; the day that flips to any public exposure, D-14's Authentik flip is a hard prerequisite, not a nice-to-have.
**Backend URL during dev (confirmed).** Production `https://dispatch.herbylab.dev/` over the tailnet — the same origin/route the web client uses. No local/herbys-dev-direct fallback build variant for v1 (round-tripping through Traefik is acceptable at this scope; a direct-to-uvicorn debug variant can be added later if iteration speed demands it, not now).
**Transport (p4 D-6, backend already settled).** The backend transport fork is resolved and live — Android consumes the shipped contract, it does not relitigate it. "Drop WebSocket-only" means dropping the *Android app's* shipped WS (`/api/tasks/events` and the v0 `/ws` chat), not changing the backend. Android uses **REST (POST actions) + SSE (live read)** over the same OkHttp transport.
**Backend contract Android consumes (shipped + live, per checkpoint 1094):**
- `POST /api/chat` — send a message to the orchestrator.
- `GET /api/chat?thread_id=…` — chat history for a thread.
- `GET /api/threads` — thread list.
- `POST /api/threads/<id>/activate` — activate a thread (Threads tab uses **render-only** tap-to-switch this phase; whether Android calls this at all is a 4a recon item — see Open Items).
- `GET /events/stream` (SSE) with `?from_seq=N` backfill — live event read.
- **Reconcile-key on every envelope (D-12)** — render-on-final on Android is identical in shape to the web client's id-keyed path.
**Rendering (p4 D-10/D-12).** Render-on-final, keyed by the reconcile key (PD-issued UUID) — the same id-keyed path optimistic render layers onto later (Phase 5). Do not foreclose it. **Re-implement the web client's proven render logic; do not reinvent it** (see pre-mortem drift guard).
**Tap-to-switch is render-only this phase (copies Phase 3).** Switching focus changes the highlighted tile; no orchestrator-side active-thread repointing is wired from Android.
**Toolchain — headless on herbys-dev (Travis-confirmed present; 4a recon re-confirms).** Android SDK is on herbys-dev (the Lovebug worker host); Travis confirms SDK + Tailscale present. Builds are headless Gradle (`./gradlew assembleDebug`), **no emulator** (herbys-dev is a headless VM). Build verification is "compiles + APK produced," never "runs on phone." *Recon backstop: 4a prints `java -version`, `./gradlew -v`, and the SDK path as a first step — cheap confirmation that the asserted toolchain is real before the build leans on it (the thread-588 open question that prompted this).*
**Device loop — human gate, plain USB.** Lovebug builds the APK to the stable known path and reports it; **Travis transfers via plain USB, installs on the physical phone, and verifies on-device manually.** Lovebug's "done" signal for any sub-phase is **build-green + APK path + what changed** — it can never include on-device behavior. SC-2's crash-grace (≤5/day during build, trending to zero) is measured by Travis on-device; Lovebug cannot observe a crash it cannot run.
## Open Items
### Pre-phase human gate — auth-boundary verification (Travis, before 4a spawns)
`OPEN_MODE=1` makes the tailnet the *entire* trust boundary, so verify the boundary before building on it (same posture as Phase 1 measuring OAuth):
- [x] **Off-tailnet reachability test — DONE.** Confirmed `https://dispatch.herbylab.dev/` is not reachable from off-tailnet. (The wildcard `*.herbylab.dev` cert + public Cloudflare DNS means the hostname *resolves* publicly; reachability was answered by an actual off-tailnet connection attempt, not a DNS lookup.)
- [x] **CORS / origin sanity — DONE, clean.** `GET /api/threads` with a forged `Origin` returns 200 with **no `access-control-allow-origin` header** — default-deny; no CORS middleware advertising cross-origin read permission. Browser-scripted cross-origin reads are blocked. (Lower-stakes than it sounds: native Android sends no `Origin` and is not subject to CORS — this protects the web surface. Same-origin web client is unaffected because `/static` and `/api` share an origin. If the web client ever moves to a different hostname, add `CORSMiddleware` with that origin allowlisted — future-you, not Phase 4.)
- [ ] **Deploy-surface commits — OWED (Travis to run on herbys-dev; commands below).** Commit the uncommitted Traefik config and the untracked migration so the gating config is the *committed* config:
```
cd <petal-dispatch repo on herbys-dev>
git add traefik/petal-dispatch.yml traefik/temp.yml db/migrations/002_dispatch_trellis_read.sql
git commit -m "chore: commit traefik gating config + trellis-read migration (pre-Phase-4 deploy-surface reconcile)"
git push
```
*Note: `traefik/temp.yml` — confirm it is the intended gating config and not a throwaway before committing; if it's genuinely temporary, delete it instead of committing so the gating config is unambiguous.*
- [x] **SDK + Tailscale-on-phone present — Travis-confirmed.** Android SDK present + working on herbys-dev; Tailscale installed and logged into the tailnet on the phone. *(4a recon prints toolchain versions as a backstop; phone-on-tailnet is implied by the off-tailnet test passing + on-tailnet reachability working.)*
### 4a recon (answer before building UI; checkpoint if anything changes scope)
- [ ] **Toolchain sanity:** `java -version`, `./gradlew -v`, SDK path, AGP/Gradle compatible with `compileSdk 36`.
- [ ] **SSE-on-Android wire fidelity:** OkHttp SSE consumer parses the live `/events/stream` envelope (field names, event types, `?from_seq=N` backfill) exactly as the web client does. Contract is settled server-side; the question is the Kotlin consumer matching it.
- [ ] **Reconcile-key population:** which event tiers/types carry a non-NULL `message_id`? Chat rendering keys on it (D-12). Fact lives in the Phase 0 schema + writer / the web client's existing handling.
- [ ] **Threads `/activate` vs. pure render-only:** Phase 3 made tap-to-switch render-only; lean parity copies that. Confirm whether Android calls `POST /api/threads/<id>/activate` or just highlights locally. *(If it does call activate, the switch-guard 409 question below becomes reachable — flag at checkpoint, do not silently expand scope.)*
### Deferred — not answered, not reachable by lean Phase 4 surfaces
- **Worker-spawn `thread_id` override** (from 3b, event 1092) — `start_task` explicit thread_id vs. always-inherit-active. No worker-spawn surface in lean Phase 4; belongs to a later Tasks-tab increment or the orchestrator/harness split.
- **Switch-guard policy** (from 3b) — hard 409 vs. "park the worker, switch anyway." Tap-to-switch is render-only, so unreachable unless 4a recon finds Android wiring real activation against a running worker (it should not, this phase).
## Phases
### 4a — Project skeleton + transport layer proven against the live backend
**Recon first, then build the transport spine. Checkpoint findings before UI if anything changes scope.**
Build:
- [ ] Fresh Compose project in `petal-dispatch-android` on a feature branch; appId `dev.herbylab.petaldispatch`; `min/target/compileSdk` 36; Material 3 theme + OkHttp carried as patterns from the v0 scaffold (reference-only).
- [ ] REST client: `POST /api/chat`, `GET /api/chat?thread_id=…`, `GET /api/threads`, (`POST /api/threads/<id>/activate` only if recon says wire it). Base URL `https://dispatch.herbylab.dev/`.
- [ ] SSE consumer over `/events/stream` with `?from_seq=N` backfill; reconnect = re-subscribe + re-render from store (survives reconnect, FR-003 / FR-004 mobile).
- [ ] **Off-tailnet / connection-failure state renders legibly** — clear "not on tailnet / reconnecting" UI, never a spinner-of-death or unhandled exception (p4 Phase 4 edge case; FR-001-adjacent visibility; SC-2 contributor).
- [ ] Headless build green: `./gradlew assembleDebug` produces an installable debug APK; copy to `~/petal-dispatch-apks/4a-<timestamp>.apk`.
- [ ] Push the feature branch to Gitea at this milestone.
**4a done =** recon answered + checkpointed · transport layer (REST + SSE + reconnect) compiles and the SSE consumer parses the live contract · off-tailnet state renders · build-green + stable APK path + what-changed in handoff to 588. **Human gate:** Travis transfers via USB, installs, confirms the app launches and connects over the tailnet, before 4b spawns.
### 4b — Chat + Threads UI on the transport spine
UI on top of 4a's proven transport. **Re-implement the web client's render logic — do not reinvent it** (pre-mortem drift guard).
- [ ] **Chat** *(success-bar surface)* — send input → orchestrator conversational response → echo via `USER_MESSAGE` through the store (FR-005), rendered. **Orchestrator only, no worker spawn.** Input-during-run queued client-side with visible pending state (FR-007). Render-on-final keyed by reconcile key (D-10/D-12).
- [ ] **Threads** — thread list, live thread highlighted, recent context, tap-to-switch **render-only** (SC-3 ≤1 action). Source: `GET /api/threads` + SSE updates. Lovebug's web-client tiles are the reference.
- [ ] Layout: logical, function over form. Design iteration happens later with Travis — iterate-shaped surface (p4), not a spec gate. No default-tab lock.
- [ ] Headless build green: debug APK produced; copy to `~/petal-dispatch-apks/4b-<timestamp>.apk`.
- [ ] Push the feature branch to Gitea at this milestone.
**4b done =** message sent from phone → orchestrator conversational response → round-trip through store (FR-005), rendered, orchestrator-only no worker · Threads renders live state, tap-to-switch render-only · off-tailnet state legible · survives reconnect · build-green + stable APK path in handoff to 588. **Human gate:** Travis transfers via USB, installs, verifies parity-with-web on-device; SC-1 (≤3 refreshes) and SC-3 (≤1-action switch) confirmed on-device.
### Sideload / distribution
Per p4 D-9: sideload v1 (debug APK → plain-USB install). Self-hosted F-Droid behind Traefik is a fast-follow, gated on first stable v1 release with no daily crashes for 3 consecutive days — **out of Phase 4 scope.**
## Notes
**Why Android copies, doesn't pioneer.** Phase 3 is closed and live (checkpoint 1094): Threads + Chat in production, transport contract settled and proven end-to-end, journal round-trip persisting, all three DB pools attached. Lean Phase 4 deliberately drops Activity/Tasks so every Android surface has a proven web counterpart. The platform is the only new variable.
**Why the human gate sits where it does.** herbys-dev is headless (no emulator) and is the Lovebug host, so builds are autonomous but on-device behavior is unobservable to the worker. The build/verify split is structural, not a preference. Lovebug's handoffs land at build-green + APK path; Travis closes the on-device loop (USB install + verify) between spawns. This is the Android analogue of Phase 3's gated spawns, with a human verification gate replacing the worker-to-worker handoff.
**Why the auth-boundary gate is pre-phase, not in-phase.** `OPEN_MODE=1` feels safe because Tailscale is the gate, which is exactly why it goes unaudited. The Phase 4 pre-mortem (Travis's call) is not "OIDC is hard" — it is "the open backend's trust boundary quietly fails or gets punched through." Mitigation is a verification gate before any Android code, not a code change: prove the route is tailnet-only from off-tailnet (done), make the gating config the committed config (owed), sanity-check CORS (done, clean). Measure the boundary before trusting it.
**Drift guard (the quiet-breakage risk).** The fresh build must re-implement the web client's *proven* reconcile/render logic, not a fresh interpretation of it — reinventing render-on-final keying is the most likely way a solved bug returns on the new platform. Treat the web client's render path as the spec for the Kotlin one.
**Thread 588 metadata.** Slug `petal-dispatch-v3-build` and goal text reference the v3 plan/phases, pre-dating the p3→p4 reframe; checkpoints are current (1094 is p4-aware). Travis's call on refresh-vs-leave recorded separately; not a blocker for Phase 4 handoffs landing on 588.
**Gated structure.** Pre-phase auth-boundary gate (Travis) → 4a recon → checkpoint (any scope findings) → 4a transport build → human USB-install/connect gate → 4b UI build → human parity-verify gate. Sequential spawns, handoff to 588 between each.