wiki-vault/Sources/Dev/petal-dispatch-phase4-brief.md

17 KiB
Raw Permalink Blame History

created path project tags type
2026-06-06 Sources/Dev petal-dispatch-phase4-brief
petal-dispatch
lovebug
phase-4
android
kotlin
sse
fastapi
tailscale
p4
execution
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):

  • 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.)
  • 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.
  • 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.