17 KiB
| created | path | project | tags | type | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-06-06 | Sources/Dev | petal-dispatch-phase4-brief |
|
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 (compileSdk36). 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, noBuild.VERSIONbranching). 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-androidthat 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=Nbackfill — 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):
- Off-tailnet reachability test — DONE. Confirmed
https://dispatch.herbylab.dev/is not reachable from off-tailnet. (The wildcard*.herbylab.devcert + public Cloudflare DNS means the hostname resolves publicly; reachability was answered by an actual off-tailnet connection attempt, not a DNS lookup.) - CORS / origin sanity — DONE, clean.
GET /api/threadswith a forgedOriginreturns 200 with noaccess-control-allow-originheader — 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 noOriginand is not subject to CORS — this protects the web surface. Same-origin web client is unaffected because/staticand/apishare an origin. If the web client ever moves to a different hostname, addCORSMiddlewarewith 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:
Note: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 pushtraefik/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. - 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 withcompileSdk 36. - SSE-on-Android wire fidelity: OkHttp SSE consumer parses the live
/events/streamenvelope (field names, event types,?from_seq=Nbackfill) 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
/activatevs. pure render-only: Phase 3 made tap-to-switch render-only; lean parity copies that. Confirm whether Android callsPOST /api/threads/<id>/activateor 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_idoverride (from 3b, event 1092) —start_taskexplicit 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-androidon a feature branch; appIddev.herbylab.petaldispatch;min/target/compileSdk36; 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>/activateonly if recon says wire it). Base URLhttps://dispatch.herbylab.dev/. - SSE consumer over
/events/streamwith?from_seq=Nbackfill; 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 assembleDebugproduces 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_MESSAGEthrough 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.