Files
arah2/.planning/phases/06-turn-by-turn-guidance-live-driving/06-06-PLAN.md
T

254 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 06-turn-by-turn-guidance-live-driving
plan: 06
type: execute
wave: 3
depends_on: [06-02, 06-03, 06-04, 06-05]
files_modified:
- web/src/telegram/backbutton.ts
- web/src/map/follow.ts
- web/src/guidance/camera.ts
- web/src/route/route.ts
- web/src/main.ts
- web/test/route-surface.test.mjs
autonomous: true
requirements: [GUID-02, GUID-05, GUID-06, PX-01, PX-03]
must_haves:
truths:
- "Guidance activates when the route is drawn and the first accepted fix is ≥ GUIDANCE_START_ALONG_M along it — never without a route (PX-03's precondition)"
- "The Telegram BackButton walks search → routes → guidance → (confirm) → routes → search and never dead-ends; close() fires only at the true root (PX-01)"
- "BackButton from guidance opens the abandon confirm card; back while the confirm is open means CONTINUE (D-09)"
- "The camera rides the SNAPPED line (locked follow, padding {top:320,bottom:64,left:64,right:64}, pitch 35, north-up); user pan unlocks → the recenter control returns (D-10)"
- "A missed turn flows to a silent /api/route reroute and the new line snaps in — banner calm, no blame (GUID-02 wired)"
- "Minimize POSTs the envelope (keepalive, best-effort D-13); re-open DELETEs it (cron pauses — structural); a fresh boot with ?g=<id> resumes at the progressed position (GUID-05/GUID-06)"
- "Arrival AND abandon both: delete the envelope (idempotent), stop the watch, release close protection, show the trip-ended card (D-14)"
artifacts:
- path: "web/src/guidance/camera.ts"
provides: "locked-follow guidance camera over the snapped line"
exports: ["GUIDANCE_CAMERA_PADDING", "GUIDANCE_ZOOM", "GUIDANCE_EASE_MS", "createGuidanceCamera"]
- path: "web/src/route/route.ts"
provides: "the guidance controller wiring: pump, effects dispatch, chrome updates, envelope flows"
- path: "web/src/telegram/backbutton.ts"
provides: "the context-aware full-state-graph wire (predicate + handler as one change)"
key_links:
- from: "web/src/route/route.ts"
to: "web/src/guidance/guidance.ts"
via: "watch → shouldAcceptFix → stepGuidance → effects"
pattern: "stepGuidance"
- from: "web/src/route/route.ts"
to: "web/src/guidance/banner.ts"
via: "chrome re-render per accepted fix"
pattern: "buildGuidanceBanner"
- from: "web/src/route/route.ts"
to: "/api/guidance"
via: "POST on deactivated (keepalive), DELETE on activated + trip end"
pattern: "/api/guidance"
- from: "web/src/main.ts"
to: "routing/src/http-source.ts"
via: "engine-a first, fixture fallback — the composed ladder, code-constructed"
pattern: "createHttpRoutingSource"
- from: "web/test/route-surface.test.mjs"
to: "web/src/route/route.ts"
via: "RS6 re-pinned (guidance IMPLEMENTATION lives in web/src/guidance/) + the PX-01 full-walk test"
pattern: "RS6|walkBack"
---
<objective>
Wire the guidance brain (06-02) into the live shell through the chrome (06-05) and the server (06-04/03): the full PX-01 BackButton state graph, the D-10 locked-follow camera, the fix pump dispatching banner/list/card/haptics/reroute, and the minimize/resume/trip-end envelope flows — completing GUID-02/05/06's client half and D-14's in-app half.
Purpose: this is where navigation becomes behavior. The BackButton walk (PX-01) only exists now that the full state graph does; the envelope lifecycle (D-11's structural foreground gate) only works when the app POSTs on minimize and DELETEs on return.
Output: camera + pump + BackButton graph + envelope flows wired in route.ts/main.ts, RS6 re-pinned, PX-01 walk proven with a recording fake.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
<interfaces>
<!-- From 06-02 (web/src/guidance/): createGuidanceSession/stepGuidance (events fix |
reroute-started | reroute-failed | reroute-applied; effects haptic | reroute-requested |
arrival), projectAlongRoute, watch.ts {shouldAcceptFix, createGuidanceWatch},
eta.ts {updateEta, composeEtaClock}, constants GUIDANCE_START_ALONG_M etc. -->
<!-- From 06-05 (web/src/guidance/): buildGuidanceBanner / buildManeuverList / buildGuidanceCard /
buildConfirmCard / buildTripEndCard / startGantryBlink / stopGantryBlink / GLYPHS;
fireHaptic(wa, cue); engageCloseProtection(wa) / releaseCloseProtection(wa);
webapp.ts now has HapticFeedback?, disableVerticalSwipes?, enableClosingConfirmation?,
isActive?, onEvent("activated"|"deactivated"). -->
<!-- From 06-04: createHttpRoutingSource({ endpoint, bearer, fetchImpl? }) — backend "engine-a",
fail-honest nulls. From 06-03: POST /api/guidance → {id}; GET/DELETE /api/guidance?id=.
Envelope snapshot: {destinationId, destinationName, distanceM, durationS, geometry
(polyline6), startedAtSec}. Session token: web/src/session/store.ts getSession(). -->
<!-- Existing seams (PATTERNS): wireBackButton(wa, hasOverlay, walkBack) — predicate + handler
as ONE change; createFollowController(map, onModeChange) with the movestart originalEvent
unlock law; createRouteController(deps) — all DOM/map concerns injected (RS gates run in
Node); recenter control data-attribute states; console law: ARAHARAH-GUIDE state=<state>
tokens only, never coordinates (the RS8 family). -->
<!-- UI-SPEC camera table (binding): guidance start → easeTo snapped, padding {top:320,
bottom:64,left:64,right:64}, pitch 35, zoom 16 (GUIDANCE_ZOOM), 400 ms (GUIDANCE_EASE_MS);
per accepted fix → easeTo the SNAPPED point; user pan (originalEvent) unlocks; recenter
re-locks; reroute snap-in → NO camera jump (setData only); trip end → camera holds.
Bearing NEVER rotates (north-up, recorded call). -->
<!-- UI-SPEC BackButton contract (binding): guidance Back → abandon confirm card; confirm open
+ Back → CONTINUE (dismiss, stay); YES, ABANDON → teardown → route card state; trip-ended
card Back → search root; route-state Back → Phase 5 walk. -->
</interfaces>
**RS6 re-pin (PATTERNS caution 3):** the gate currently bans `progress`/`maneuver`/`btn-primary` tokens in web/src/route/. This plan's route.ts gains guidance REFERENCES — re-pin RS6 in the same change: the ban becomes "guidance IMPLEMENTATION lives in web/src/guidance/ — route/ files may import from ../guidance/ and call it, but must not define maneuver/progress rendering inline" (structural: the tokens are allowed only on `import … from "../guidance/…"` lines and direct call sites).
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: Guidance camera (locked follow) + BackButton full state graph</name>
<files>web/src/guidance/camera.ts, web/src/map/follow.ts, web/src/telegram/backbutton.ts, web/test/route-surface.test.mjs</files>
<read_first>
- web/src/map/follow.ts (the mode machine + programmatic-move lock + CAMERA_PADDING_PX — extend, don't reinvent)
- web/src/telegram/backbutton.ts (wireBackButton — the predicate + handler-as-one-change law; the Phase 5 header comment reserved this extension)
- web/src/ui/recenter.ts (data-attribute state swaps; the control returns unlocked on pan)
- web/test/route-surface.test.mjs RS7 (the recording fake-wa walk test — extend for the full graph)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md Camera Behavior + BackButton Contract tables (BINDING parameters)
</read_first>
<behavior>
- createGuidanceCamera.easeToSnapped(point): fakeMap.easeTo recorded with center=snapped point, padding {top:320,bottom:64,left:64,right:64}, pitch 35, zoom 16, duration 400 — bearing NEVER passed (north-up)
- programmatic moves (originalEvent undefined) do NOT unlock follow; a movestart WITH originalEvent unlocks → recenter control's follow notch clears (Phase 3 semantics)
- follow.ts's existing follow(fix) behavior is unchanged when called without opts (Phase 3/5 routes keep their camera law)
- PX-01 walk with the recording fake: root → hidden; route state → shown, click → Phase 5 walk back; guidance → shown, click → confirm card opens (NOT teardown); confirm open → click → confirm dismissed (CONTINUE), guidance alive; YES ABANDON → chrome clears → route card state → click → Phase 5 walk → destination card (root) → hidden; trip-ended card → click → search root, hidden; close() recorded ZERO times anywhere except an explicit root close path
</behavior>
<action>
1. `web/src/map/follow.ts`: extend `follow(fix, opts?: { padding?: {top,bottom,left,right}; pitch?: number; zoom?: number; durationMs?: number })` — opts spread over the CURRENT defaults so existing callers are byte-identical in behavior. Keep the lock/originalEvent law untouched.
2. `web/src/guidance/camera.ts`: named exports `GUIDANCE_CAMERA_PADDING = { top: 320, bottom: 64, left: 64, right: 64 }`, `GUIDANCE_ZOOM = 16`, `GUIDANCE_EASE_MS = 400`, `GUIDANCE_CAMERA_PITCH = 35`; `createGuidanceCamera(map, followController)` returning `{ easeToSnapped(point: RoutePoint): void; onRerouteApplied(): void }` — easeToSnapped calls followController.follow(point, { padding: GUIDANCE_CAMERA_PADDING, pitch: GUIDANCE_CAMERA_PITCH, zoom: GUIDANCE_ZOOM, durationMs: GUIDANCE_EASE_MS }); onRerouteApplied is a documented no-op camera-wise (setData happens in the route layer — UI-SPEC: no camera jump on reroute).
3. `web/src/telegram/backbutton.ts`: grow the wire's doc-header to the 3+ state graph and extend the predicate/handler so `walkBack` is CONTEXT-AWARE via an injected `resolveAction(): BackAction` where `BackAction = { kind: "hidden" } | { kind: "close" } | { kind: "callback"; fn: () => void }` — keep the old two-argument call shape working (hasOverlay + walkBack compose into the resolver internally) so Phase 5 call sites and RS7 stay valid; the visible-iff-pushed-state and never-close-from-pushed laws hold for every new state.
4. RED→GREEN in `web/test/route-surface.test.mjs`: extend RS7's recording-fake block with the full-walk scenario per the behavior list (the confirm-card steps assert the CALLBACKS the fake receives — the confirm surface itself is 06-05's, wired in task 2), plus camera tests with the route-surface fakeMap pattern (easeTo args recorded; unlock law).
</action>
<verify>
<automated>npm test -w web && npm run web:typecheck</automated>
</verify>
<acceptance_criteria>
- camera.ts exports the three constants with EXACT UI-SPEC values (test pins them, RS4 style)
- fakeMap easeTo assertions: padding/pitch/zoom/duration exact; no bearing key ever present
- follow.ts without opts behaves byte-identically (existing follow tests untouched-green)
- RS7 extended: full walk recorded, close() count === 0 across all pushed-state clicks
- typecheck clean
</acceptance_criteria>
<done>The camera rides the snapped line at the UI-SPEC pose and unlocks only on user gesture; the BackButton resolver walks every state with zero dead ends and zero premature closes</done>
</task>
<task type="auto" tdd="true">
<name>task 2: The guidance pump in route.ts — start, effects, chrome, reroute</name>
<files>web/src/route/route.ts, web/test/route-surface.test.mjs</files>
<read_first>
- web/src/route/route.ts IN FULL (the controller you are evolving: injected deps, setState funnel, fail-honest catch, console law, D-10 reserved note)
- web/src/guidance/guidance.ts + watch.ts + eta.ts (06-02 contract — pump against it, never re-implement it)
- web/src/guidance/banner.ts + haptics.ts + close-protection.ts (06-05 chrome + guards)
- web/src/route/route-card.ts (the card the guidance card evolves from)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md State Coexistence table (BINDING: OFF_ROUTE shows no premature signal; REROUTING holds last honest numbers; NO REROUTE state line; list re-render on reroute-apply)
</read_first>
<behavior>
- route drawn + fix < GUIDANCE_START_ALONG_M along → still Phase 5 route state (no guidance chrome)
- route drawn + fix ≥ 30 m along → guidance activates: engageCloseProtection, disableVerticalSwipes path via engage, [data-guidance="active"] on the shell root, banner + guidance card + collapsed list mount, camera easeToSnapped, ARAHARAH-GUIDE state=on_route token logged (category-only)
- accepted fixes pump stepGuidance: banner distance/street update, ETA via updateEta + composeEtaClock, progress bar pct = alongM/totalM, haptic effects → fireHaptic, OFF_ROUTE → banner UNCHANGED (no premature signal), REROUTING → register swap + reroute copy + distance "—" + gantry blink start
- reroute-requested effect → source.route({origin: fix, destinationId}) (the composed ladder from task 3); success → reroute-applied event → new line setData, list re-render (scroll top), gantry stop, camera NO jump; failure/throw → reroute-failed → NO REROUTE banner state + card state line (fail-honest, calm)
- arrival effect → trip-end flow (task 3's teardown: DELETE envelope idempotent, stop watch, release protection, trip-ended card, chrome clears)
- END TRIP tap → confirm card (bottom); YES, ABANDON → same teardown; CONTINUE → dismiss
</behavior>
<action>
1. Extend `RouteControllerDeps` with injected guidance concerns: `wa` (TelegramWebAppLike | null), `shellRoot` (the element receiving data-guidance), `register` (the register shower), banner/card/list mount points, `console` stays the module's own (state tokens only: `ARAHARAH-GUIDE state=<state>` — RS8 family; never coordinates).
2. Implement the guidance controller INSIDE route.ts's injected-deps shape: a `startGuidance(model)` / `pumpFix(fix)` / `teardownGuidance(reason: "arrival" | "abandon")` triple held in the controller closure, using ONLY 06-02's machine + 06-05's builders (zero maneuver math inline — RS6's re-pinned ban enforces it). Pump: `shouldAcceptFix` gate → `stepGuidance` → effects dispatch (haptics via fireHaptic; reroute via the injected source; arrival → teardown) → chrome updates (banner five slots, ETA via updateEta/composeEtaClock, progress pct, list rows via the model's steps + projectAlongRoute). REROUTING: startGantryBlink on mount, stopGantryBlink on resolve. The confirm card + trip-end card mount into the injected bottom/center registers per UI-SPEC placement.
3. RED→GREEN in `web/test/route-surface.test.mjs` with the fakeDeps pattern: guidance activation gate (stationary vs moving first fix), pump updates (banner distance from a known fix, ETA state, progress pct), OFF_ROUTE no-premature-signal, REROUTING calm state + exactly one source.route call, reroute failure → NO REROUTE + card state line, reroute success → list re-rendered + no camera jump, END TRIP → confirm → YES ABANDON → teardown effects recorded (close-protection release + watch stop + DELETE fetch recorded via stubFetch), console tokens category-only.
4. RE-PIN RS6 in the same change: tokens `progress`/`maneuver`/`btn-primary` banned in web/src/route/ EXCEPT on `from "../guidance/…"` import lines and direct call expressions of imported names (structural scan per the context note).
</action>
<verify>
<automated>npm test -w web && npm run web:typecheck</automated>
</verify>
<acceptance_criteria>
- route-surface.test.mjs green including: activation gate, pump updates, no-premature-signal, exactly-one-reroute-call, NO REROUTE degradation, teardown-on-abandon (release + stop + DELETE recorded), ARAHARAH-GUIDE tokens category-only
- RS6 re-pinned and green (guidance implementation lives in web/src/guidance/)
- grep `ARAHARAH-GUIDE state=` matches web/src/route/route.ts; grep `console\.` in route.ts shows only state-token lines (no `${lat` / coordinate interpolation)
- typecheck clean
</acceptance_criteria>
<done>Driving works end-to-end in Node fakes: motion starts guidance, fixes drive the cockpit, excursions self-heal silently through the real /api/route, and both exit paths tear down cleanly</done>
</task>
<task type="auto" tdd="true">
<name>task 3: Envelope flows — minimize POST, resume GET, trip-end DELETE + composed ladder</name>
<files>web/src/main.ts, web/src/route/route.ts, web/test/route-surface.test.mjs</files>
<read_first>
- web/src/main.ts lines 40–70 and 340–470 (the composition root: fixture source construction, route controller creation, session/boot choreography, BackButton wiring)
- web/src/session/store.ts (getSession — the Bearer getter for http-source)
- bot/src/kv/guidance-sessions.ts (the frozen snapshot fields the POST body must carry EXACTLY)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 5 (envelope lifecycle as structural foreground gate; write-at-minimize + earliest-signal belt) + Pitfall 2 (the minimize race — fetch keepalive)
- web/test/destination-preload.test.mjs (the ?param reader pattern — ?g= follows it)
</read_first>
<behavior>
- deactivated event during guidance → POST /api/guidance with the frozen snapshot shape (geometry re-encoded via encodePolyline6 from the live model; startedAtSec = trip start) using fetch(…, { keepalive: true }) — recorded by stubFetch even when the harness simulates immediate freeze
- activated event during guidance → DELETE /api/guidance?id=<stored id> (cron pauses — structural); the id is retained for trip-end idempotent DELETE
- fresh boot with ?g=<uuid> → GET /api/guidance?id= → envelope → model rebuilt (decodePolyline6 + deriveCumulativeDistanceM + destination fields) → RESUMING state (projected numbers from startedAtSec + avg speed; GUIDE // RESUMING banner + gantry) → first accepted fix → live ON_ROUTE; a failed/foreign GET → honest destination-less boot (Phase 4 flow), never a fake route
- composed ladder in main.ts: engine-a first, fixture fallback on null — `route: (req) => (await http.route(req)) ?? (await fixture.route(req))` code-constructed (no env reads)
- trip end (arrival or abandon) → DELETE envelope (404-tolerant), watch stop, close-protection release, data-guidance removed, trip-ended card; BackButton from trip-end walks to search root
</behavior>
<action>
1. In `main.ts`: build the composed RoutingSource ladder beside the existing fixture construction (code-constructed per the types.ts no-registry law): `const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() });` then the wrapper `{ name: "ladder", route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)) }` handed to createRouteController. Keep the golden-demo path: with no graph/no token, engine-a nulls fall through to the fixture corpus (captures stay stable).
2. Envelope lifecycle in the controller closure (exposed to main.ts via the controller): `onDeactivated` → POST (keepalive fetch, body = frozen snapshot only — id/chat_id/created_at are SERVER-minted; store the returned id); `onActivated` → DELETE ?id; `teardownGuidance` → DELETE ?id (tolerate 404) + watch stop + release + trip-end card. Wire `wa.onEvent?.("deactivated"|"activated", …)` with capability guards.
3. `?g=` resume in main.ts's boot choreography (destination-preload pattern): read `new URLSearchParams(location.search).get("g")`, UUID-validate, GET envelope, rebuild the model, create the guidance session in RESUMING state (projection: `remainingM = distanceM × (1 − min(1, (nowSec − startedAtSec) × avgSpeed / distanceM))`, avgSpeed = distanceM/durationS), render RESUMING chrome (banner register + gantry + projected ETA card numbers), flip to live on first accepted fix.
4. RED→GREEN in `web/test/route-surface.test.mjs`: stubFetch records POST (keepalive true, snapshot fields exactly the frozen six), DELETE on activated + on teardown (idempotent second DELETE tolerated), ?g= boot → RESUMING → first fix → ON_ROUTE (fake wa events + URL stub), failed GET → Phase 4 boot flow (no guidance chrome).
</action>
<verify>
<automated>npm test -w web && npm run web:typecheck</automated>
</verify>
<acceptance_criteria>
- stubFetch assertions: POST /api/guidance body has EXACTLY destinationId/destinationName/distanceM/durationS/geometry/startedAtSec (no id/chat_id/created_at — server-minted), keepalive true; DELETE called with the stored id on activated AND teardown
- ?g= resume test: RESUMING state rendered (GUIDE // RESUMING register + projected numbers), first fix flips to live ON_ROUTE
- main.ts grep `createHttpRoutingSource` and `?? (await` (the composed ladder) present; grep for env reads in the backend selection → 0 (code-constructed law)
- full web suite green; typecheck clean
</acceptance_criteria>
<done>The envelope lifecycle is structural (no key, no push), the minimize race is belt-and-braces (keepalive + earliest signal), resume works from a killed webview via ?g=, and both trip ends execute the deletion + honest messaging</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Minimize/freeze → envelope POST | A backgrounding webview may freeze mid-fetch (Pitfall 2) — best-effort honesty, not a security boundary |
| ?g= deep link → resume GET | The guidance id is an opaque UUID traveling in the URL; ownership enforced server-side by the claim check |
| User gestures → camera | Only originalEvent-bearing moves unlock follow — synthetic moves cannot hijack the driving camera |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-05 | Information Disclosure | envelope POST + reroute calls | mitigate-by-design | Exactly three positions ever leave the device: reroute origins (real excursions only), the minimize envelope (user-initiated handoff), nothing else. On-route fixes stay local (06-02's structural gate). The envelope carries the frozen route snapshot only — no live coordinate stream. |
| T-06-04 | Information Disclosure | console + state tokens | mitigate | ARAHARAH-GUIDE state tokens only — tested to contain no coordinates (RS8 family); no payload/claim values in any log line. |
| T-06-06 | Tampering (old-client crash) | onEvent("deactivated"/"activated") wiring | mitigate | `wa.onEvent?.()` optional chains; event names ignored by older clients by contract; capability guards proven in 06-05's fakes. |
| — | Spoofing (?g= probe) | resume GET | mitigate (server, 06-03) | Foreign/absent id → 404 → honest Phase 4 boot; the client renders no guidance chrome without a validated envelope. |
| — | DoS (self) | reroute requests | accept | The D-04 storm guard (20 s) bounds reroute rate client-side; replay tests prove exactly-one-call under excursion storms. |
</threat_model>
<verification>
- `npm test -w web && npm run web:typecheck` green (RS6 re-pinned; PX-01 full walk; envelope flows; camera laws)
- Zero dead ends in the BackButton graph (RS7 extended walk, close() only at true root)
- Console carries only ARAHARAH-GUIDE/ARAHARAH-ROUTE state tokens
</verification>
<success_criteria>
Guidance is ALIVE in the shell: motion activates it, fixes drive the cockpit, missed turns self-heal through the real router with a calm banner, minimizing hands off to the cron (envelope live), re-opening resumes at the progressed position, the BackButton walks the full graph through the abandon confirmation, and both trip ends delete the record and say so. PX-01/PX-03's observable behaviors are claimed and tested.
</success_criteria>
<output>
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-06-SUMMARY.md`
</output>