diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md
index 386ed83..91fc5b1 100644
--- a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md
+++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md
@@ -30,7 +30,7 @@ user_setup:
must_haves:
truths:
- "A maintainer can rebuild the KL road graph from Overpass with one committed script — $0, fork-friendly (D-01)"
- - "The real graph binary lands inside D-01's 30–80 MB envelope with the stats gate printing its measurement"
+ - "The real graph binary passes the enforced size gate (> 1 MB, ≤ 80 MB) with the stats gate printing its measurement — a sub-30 MB result prints D-01's expected-band warning for review rather than failing"
- "/api/route answers an arbitrary current position with a real A* route + named steps — engine A is the guidance engine (D-03)"
- "/api/route without a valid Bearer claim is 401 — the routing oracle is closed (T-06-01)"
- "A missing R2 graph degrades to honest null → the client's router-pending state (D-03), never an error"
@@ -43,6 +43,7 @@ must_haves:
exports: ["createHttpRoutingSource"]
- path: "bot/src/routing/worker-router.ts"
provides: "R2 graph load (isolate-memoized) + snap + A* + steps → RouteResult"
+ exports: ["handleRoute", "loadGraph"]
- path: "bot/wrangler.jsonc"
provides: "r2_buckets binding ROAD_GRAPH (still exactly ONE kv_namespaces — GE5)"
- path: "docs/ROAD-GRAPH.md"
@@ -128,6 +129,7 @@ names: u32 nameOffset[cnt+1] ++ u8 pool[] -->
- `node routing/scripts/build-road-graph.mjs` fetches the Overpass extract for the KL bbox, encodes the pinned CSR binary, writes routing/data/kl-roadgraph.v1.bin, prints node/edge counts + binary size + bbox, exits 0
- binary size > 80 MB (D-01 envelope) → exit 1 with the stats printed (the stats gate)
+ - binary size < 30 MB → printed WARNING naming D-01's expected band and A2's 18–50 MB forecast ("below the expected 30–80 MB band — verify the drivable-class filter") — advisory only; the ENFORCED gate stays > 1 MB, ≤ 80 MB
- ways with highway in the drivable class set only: motorway, trunk, primary, secondary, tertiary, residential, unclassified, living_street (footway/cycleway/service/path excluded — Pitfall 1)
- oneway=yes → flags bit0 on the reverse direction entry; maxspeed tags → edge seconds; class defaults: motorway 100, trunk 80, primary 60, secondary 50, tertiary 40, residential 30, unclassified 30, living_street 20 km/h
- way name tags → the name pool (unnamed ways → UNNAMED_NAME_ID)
@@ -135,7 +137,7 @@ names: u32 nameOffset[cnt+1] ++ u8 pool[] -->
- a second run with the committed file present and `--check` green needs no refetch (artifact, not input)
-Write `routing/scripts/build-road-graph.mjs` in the fetch-route-fixtures.mjs shell: header naming the D-01 boundary ("A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, web/src, or routing/src imports it"), named constants (`OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"`, `REQUEST_TIMEOUT_MS = 300_000`, `USER_AGENT`, `MAX_BINARY_BYTES = 80 * 1024 * 1024`, `DRIVABLE_CLASSES` + `CLASS_SPEED_KMH` tables, `BBOX_MARGIN_DEG = 0.05`), bbox COMPUTED at run start from `@araharah/search/src/gazetteer.ts` getEntries() + `routing/test/fixtures/golden-pairs.json` pair endpoints ± margin (printed; Open Q 4's mechanical pinning), the Overpass query `way[highway~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|living_street)$"](bbox); out geom;`, response → nodes deduplicated by rounded coord → edges with oneway/flags/speed/name → `encodeGraph` (import `../src/graph.ts`) → collect-then-write (committed file untouched on any failure, exit 1) → stats gate (size ≤ MAX_BINARY_BYTES else exit 1) → post-write summary + `next:` hint naming the upload command. `--check` mode decodes and re-prints stats. Also create `routing/data/.gitignore` containing `*.bin` (the binary is an ODbL-derived build artifact; committed artifacts are the script, docs, and the tiny test mini-graph).
+Write `routing/scripts/build-road-graph.mjs` in the fetch-route-fixtures.mjs shell: header naming the D-01 boundary ("A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, web/src, or routing/src imports it"), named constants (`OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"`, `REQUEST_TIMEOUT_MS = 300_000`, `USER_AGENT`, `MAX_BINARY_BYTES = 80 * 1024 * 1024`, `DRIVABLE_CLASSES` + `CLASS_SPEED_KMH` tables, `BBOX_MARGIN_DEG = 0.05`), bbox COMPUTED at run start from `@araharah/search/src/gazetteer.ts` getEntries() + `routing/test/fixtures/golden-pairs.json` pair endpoints ± margin (printed; Open Q 4's mechanical pinning), the Overpass query `way[highway~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|living_street)$"](bbox); out geom;`, response → nodes deduplicated by rounded coord → edges with oneway/flags/speed/name → `encodeGraph` (import `../src/graph.ts`) → collect-then-write (committed file untouched on any failure, exit 1) → stats gate (size ≤ MAX_BINARY_BYTES else exit 1; size < 30 MB prints the expected-band warning per the behavior list) → post-write summary + `next:` hint naming the upload command. `--check` mode decodes and re-prints stats. Also create `routing/data/.gitignore` containing `*.bin` (the binary is an ODbL-derived build artifact; committed artifacts are the script, docs, and the tiny test mini-graph).
Then RUN it (network fetch, minutes) and confirm the stats gate passes; record the printed counts in the plan summary.
@@ -173,7 +175,7 @@ Then RUN it (network fetch, minutes) and confirm the stats gate passes; record t
1. `routing/src/http-source.ts`: `createHttpRoutingSource(opts: { endpoint: string; bearer: () => string | null; fetchImpl?: typeof fetch }): RoutingSource` — name "engine-a"; POST JSON {origin, destinationId}; Authorization only when bearer() returns non-null; response.json() → defensive shape check (copy fixture-source's isUsablePair style: every field type-checked, non-finite refused, try/catch → null); export from `routing/src/index.ts` alongside a header-note update (backend #2 live, ladder: fixture → engine-a → engine B documented post-v1).
-2. `bot/src/routing/worker-router.ts`: module-scope `let graphP: Promise | null = null;` + `loadGraph(env)` — `env.ROAD_GRAPH.get(GRAPH_KEY)` with `GRAPH_KEY = "kl-roadgraph.v1.bin"`, `.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()))`, catch → null (a corrupt binary degrades honest); memo never reset (per-isolate). `handleRoute(req, env): Promise`: Bearer claim (extract + getClaimByToken — reuse webhook.ts's exact helper posture), body-cap 4096 (`MAX_ROUTE_BODY_CHARS`), origin {lat,lng} numeric + inside graph.bbox (400 otherwise), `getEntryById(destinationId)` (import `@araharah/search/src/gazetteer.ts`) → null miss → 200 null; snapToGraph origin + destination → null miss → 200 null; routeAstar → null → 200 null; else build RouteResult `{ destinationId, origin, options: [{ geometry: { points: nodePath points, cumulativeDistanceM: deriveCumulativeDistanceM(points) }, summary: { distanceM, durationS }, steps: deriveSteps(...) }], backend: "engine-a" }` → 200 JSON.
+2. `bot/src/routing/worker-router.ts`: module-scope `let graphP: Promise | null = null;` + EXPORTED `loadGraph(env)` — `env.ROAD_GRAPH.get(GRAPH_KEY)` with `GRAPH_KEY = "kl-roadgraph.v1.bin"`, `.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()))`, catch → null (a corrupt binary degrades honest); memo never reset (per-isolate). Exporting loadGraph is deliberate: the cron arm (06-07 scheduled()) imports this SAME memo — one R2 GET per cold isolate regardless of which arm warms it; a second R2 loader anywhere in bot/src fails review. `handleRoute(req, env): Promise`: Bearer claim (extract + getClaimByToken — reuse webhook.ts's exact helper posture), body-cap 4096 (`MAX_ROUTE_BODY_CHARS`), origin {lat,lng} numeric + inside graph.bbox (400 otherwise), `getEntryById(destinationId)` (import `@araharah/search/src/gazetteer.ts`) → null miss → 200 null; snapToGraph origin + destination → null miss → 200 null; routeAstar → null → 200 null; else build RouteResult `{ destinationId, origin, options: [{ geometry: { points: nodePath points, cumulativeDistanceM: deriveCumulativeDistanceM(points) }, summary: { distanceM, durationS }, steps: deriveSteps(...) }], backend: "engine-a" }` → 200 JSON.
3. `bot/src/webhook.ts`: add the `/api/route` arm delegating to handleRoute (405 guard for non-POST). `bot/src/config.ts`: add `ROAD_GRAPH: R2Bucket; // D-01 graph binary — R2 binding, first in the repo (never a KV namespace — GE5)`. `bot/wrangler.jsonc`: add `"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }]` with a law comment; kv_namespaces stays exactly one entry.
4. RE-PIN `bot/test/route-privacy.test.mjs` in the same change: RP1 → the /api/route arm must reference the Bearer-claim derivation (structural: the handler path calls the claim helper; a bare unauthenticated fetch-forward shape fails); no external routing hostnames (`grep` for non-Telegram/non-Cloudflare API hosts). RP2 → allow-list `@araharah/search` + `@araharah/routing` in bot/src imports; any other `@araharah/*` fails. Extend `bot/test/webhook.test.mjs` with the /api/route behavior block (R2 mini-graph fake + mini graph binary loaded from routing/test/fixtures/mini-graph.bin via fs).
@@ -182,7 +184,7 @@ Then RUN it (network fetch, minutes) and confirm the stats gate passes; record t
- routing/src/http-source.ts exports createHttpRoutingSource (name "engine-a"); index.ts re-exports it
- - bot/src/routing/worker-router.ts: module-scope graphP memo (grep `graphP ??=` or equivalent), GRAPH_KEY = "kl-roadgraph.v1.bin", null on absent/corrupt graph
+ - bot/src/routing/worker-router.ts: module-scope graphP memo (grep `graphP ??=` or equivalent), exported loadGraph, GRAPH_KEY = "kl-roadgraph.v1.bin", null on absent/corrupt graph
- webhook.test.mjs: 401 unauth, 413 cap, 400 malformed origin, 200-null on unknown dest / snap miss / absent graph, 200 RouteResult happy path via R2 fake, backend === "engine-a", options.length === 1
- route-privacy.test.mjs: RP1 (auth-bound routing) + RP2 (allow-list search+routing only) re-pinned and green; GE5 green (one kv_namespaces)
- bot/src/config.ts carries the ROAD_GRAPH: R2Bucket line with a law comment
@@ -247,7 +249,7 @@ Then RUN it (network fetch, minutes) and confirm the stats gate passes; record t
- `npm run routing:test && npm test -w bot` green; both typechecks clean
-- Real graph: `--check` green, size inside the 30–80 MB envelope, stats printed
+- Real graph: `--check` green, size inside the enforced gate (1–80 MB; sub-30 MB prints the band warning), stats printed
- CPU gate: p95 ≤ 8 ms printed (A1 retired) or escalation recorded
- RP1/RP2 re-pinned green; GE5 (one KV namespace) still green
diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md
index 97ff0e9..3439292 100644
--- a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md
+++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md
@@ -167,12 +167,11 @@ Output: glyphs + 5 builders + 2 guard modules + webapp contract members + string
- task 2: Banner + list + guidance/confirm/trip-end card builders + shell.css
- web/src/guidance/banner.ts, web/src/shell.css, web/test/guidance-surface.test.mjs
+ task 2: Banner + maneuver-list builders + gantry blink
+ web/src/guidance/banner.ts, web/test/guidance-surface.test.mjs
- web/src/route/route-card.ts (THE builder law: actions interface first, createElement+textContent only, aria on interactive groups, instrument formats composed in code)
- - web/src/ui/strip.ts + the strip section of web/src/shell.css (what [data-guidance="active"] compacts — layout only, strip.ts logic untouched)
- - .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md sections: Shell Layout, Maneuver Banner, Maneuver List, Guidance Card, Abandon-Route Confirm Card, Trip-Ended Card, State Coexistence
+ - .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md sections: Maneuver Banner, Maneuver List, State Coexistence (the banner's four registers + calm-state slot swaps)
- web/test/route-surface.test.mjs RS5/RS9 (the structural-gate style guidance-surface mirrors)
@@ -180,6 +179,36 @@ Output: glyphs + 5 builders + 2 guard modules + webapp contract members + string
- the banner root is a
+
+1. `web/src/guidance/banner.ts` — create the module with the banner-surface exports, each `(locale, view, actions) => HTMLElement` with a view interface per the behavior list (copy route-card.ts's actions-interface-first structure):
+ - `buildGuidanceBanner(locale, view: { state: GuidanceState; glyph: GlyphKind; distanceM: number | null; street: string | null; listOpen: boolean }, actions: { onToggle(): void })`
+ - `buildManeuverList(locale, rows: Array<{ kind: GlyphKind; street: string; distanceM: number; rowState: "next" | "future" | "past" }>, actions: { onDismiss(): void })` (the tap-out layer dismisses)
+ - `startGantryBlink(el: HTMLElement): void` / `stopGantryBlink(el: HTMLElement): void` (setInterval 500 ms toggling a data attribute; one interval per element, stop clears)
+ All copy via `strings[locale]`; registers composed in code (`\`${strings[locale].guideRegister} // NEXT\`` style instrument constants); formats via a local `fillTemplate`-style composer (route-card precedent).
+2. RED→GREEN `web/test/guidance-surface.test.mjs` (the RS style: Node-24 dynamic .ts imports, minimal document fake — reuse the route-surface DOM-stub approach): behavioral tests per the behavior list (five-slot-every-state banner, list row states, gantry blink start/stop, banner/list aria) + structural gates — innerHTML ban with the svgHost whitelist (exactly one sanctioned site), inline-copy ban (RS9-analog grep of banner.ts for English/Malay literals outside strings imports — allowed: the instrument register words NEXT/REROUTING/RESUMING/NO REROUTE/CONFIRM/END and unit formats).
+
+
+ npm test -w web
+
+
+ - guidance-surface.test.mjs green with: five-slot-every-state banner test, list row-states test, gantry blink start/stop test, aria assertions (banner button aria-expanded/controls, list region label)
+ - structural: banner.ts innerHTML appears ONLY in svgHost against GLYPHS[kind]
+ - typecheck clean
+
+ The banner and list render: five-slot discipline with the calm REROUTING/RESUMING/NO REROUTE states and the honest collapsible list — Node-tested DOM with the single sanctioned innerHTML site
+
+
+
+ task 3: Guidance/confirm/trip-end card builders + shell.css guidance block
+ web/src/guidance/banner.ts, web/src/shell.css, web/test/guidance-surface.test.mjs
+
+ - web/src/guidance/banner.ts (task 2's module — the card builders APPEND to it; banner/list builders untouched)
+ - web/src/ui/strip.ts + the strip section of web/src/shell.css (what [data-guidance="active"] compacts — layout only, strip.ts logic untouched)
+ - .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md sections: Shell Layout, Guidance Card, Abandon-Route Confirm Card, Trip-Ended Card
+
+
- buildGuidanceCard: GUIDE register, destination headline, telemetry row (minutes telemetry-num + MIN + {km} KM + ETA HH:MM), 4px progress bar (role="progressbar", aria-valuemin 0 / max 100 / now pct, aria-label ariaTripProgress), state line ONLY in no_reroute (guideNoRerouteBody), actions = ONE .btn-secondary END TRIP — no .btn-primary anywhere in guidance chrome except the confirm card
- buildConfirmCard: GUIDE // CONFIRM + exact en/ms headline/body + .btn-primary "YES, ABANDON" + .btn-secondary "CONTINUE"; bottom register placement class
- buildTripEndCard: GUIDE // END + exact headline/body, no actions
@@ -187,30 +216,28 @@ Output: glyphs + 5 builders + 2 guard modules + webapp contract members + string
- structural: zero "transition"/"animation" CSS in the new shell.css block; zero hex literals (token vars only); zero border-radius; zero box-shadow
-1. `web/src/guidance/banner.ts` — five exported builders, each `(locale, view, actions) => HTMLElement` with a view interface per the behavior list (copy route-card.ts's actions-interface-first structure):
- - `buildGuidanceBanner(locale, view: { state: GuidanceState; glyph: GlyphKind; distanceM: number | null; street: string | null; listOpen: boolean }, actions: { onToggle(): void })`
- - `buildManeuverList(locale, rows: Array<{ kind: GlyphKind; street: string; distanceM: number; rowState: "next" | "future" | "past" }>, actions: { onDismiss(): void })` (the tap-out layer dismisses)
- - `startGantryBlink(el: HTMLElement): void` / `stopGantryBlink(el: HTMLElement): void` (setInterval 500 ms toggling a data attribute; one interval per element, stop clears)
+1. `web/src/guidance/banner.ts` — APPEND the three card builders (signatures per the behavior list; route-card.ts actions-interface-first law):
- `buildGuidanceCard(locale, view: { destinationName: string; minutesRemaining: number; kmRemaining: number; etaClock: string; progressPct: number; showNoReroute: boolean }, actions: { onEndTrip(): void })`
- `buildConfirmCard(locale, actions: { onAbandon(): void; onContinue(): void })` and `buildTripEndCard(locale): HTMLElement`
- All copy via `strings[locale]`; registers composed in code (`\`${strings[locale].guideRegister} // NEXT\`` style instrument constants); formats via a local `fillTemplate`-style composer (route-card precedent).
+ All copy via `strings[locale]`; registers composed in code; instrument formats via the fillTemplate-style composer from task 2.
2. `web/src/shell.css` — append the guidance block, token vars ONLY: banner band (matte-black chassis, min-height 96px, padding space-sm space-md, full-bleed, below safe-top + space-sm), 1px chassis-subtle banner→strip separator, `[data-guidance="active"]` strip compaction (min-height 24px, padding-block space-xs, telemetry cluster display:none — layout only), list panel + row anatomy + separators + lamp, guidance-card telemetry row + 4px progress bar (chassis-subtle track, 1px matte-black border, signal-orange fill, width set by inline style from the pct), ink colors per the hierarchy (register chassis-subtle, street soft-beige, distance pure-white). NO transition/animation/box-shadow/border-radius/hex anywhere in the block.
-3. RED→GREEN `web/test/guidance-surface.test.mjs` (the RS style: Node-24 dynamic .ts imports, minimal document fake — reuse the route-surface DOM-stub approach): behavioral tests per the behavior list + structural gates — innerHTML ban with the svgHost whitelist (exactly one sanctioned site), inline-copy ban (RS9-analog grep of banner.ts for English/Malay literals outside strings imports — allowed: the instrument register words NEXT/REROUTING/RESUMING/NO REROUTE/CONFIRM/END and unit formats), CSS block scan (no transition|animation|box-shadow|border-radius|#[0-9a-f]{3,6} in the appended guidance section), aria assertions (banner button aria-expanded/controls, list region label, progressbar valueattrs).
+3. RED→GREEN: extend `web/test/guidance-surface.test.mjs` with the card tests (confirm/trip-end copy asserting the exact strings-module keys, END TRIP is btn-secondary and the only card action, progressbar aria) + the CSS block scan (no transition|animation|box-shadow|border-radius|#[0-9a-f]{3,6} in the appended guidance section) + the strip.ts-untouched structural check.
npm test -w web
- - guidance-surface.test.mjs green with: five-slot-every-state banner test, list row-states test, gantry blink start/stop test, confirm/trip-end copy tests asserting the exact strings-module keys, END TRIP is btn-secondary and the only card action, progressbar aria assertions
- - structural: banner.ts innerHTML appears ONLY in svgHost against GLYPHS[kind]; grep "btn-primary" in banner.ts matches ONLY the confirm card builder
+ - guidance-surface.test.mjs green with: confirm/trip-end copy tests asserting the exact strings-module keys, END TRIP is btn-secondary and the only card action, progressbar aria valueattrs
+ - structural: grep "btn-primary" in banner.ts matches ONLY the confirm card builder
- shell.css guidance block: grep `transition|animation|box-shadow|border-radius` → 0 matches in the appended section; grep `#[0-9a-fA-F]{3,6}` → 0 matches (token vars only)
- strip.ts untouched (grep confirms no changes; compaction is pure CSS under [data-guidance="active"])
+ - typecheck clean
- The cockpit renders: banner five-slot discipline with calm states, honest collapsible list, evolved guidance card with progress instrument, guarded confirm surface, and trip-end card — all token-pure and Node-tested
+ The cockpit's instruments complete: evolved guidance card with progress bar, guarded confirm surface, trip-end card, and the token-pure shell.css block that dresses the whole cockpit
- task 3: Capability guards — haptics + close protection + webapp members + gate bumps
+ task 4: Capability guards — haptics + close protection + webapp members + gate bumpsweb/src/guidance/haptics.ts, web/src/guidance/close-protection.ts, web/src/telegram/webapp.ts, web/test/guidance-surface.test.mjs, web/test/route-surface.test.mjs, web/test/build-artifact.test.mjs
- web/src/telegram/webapp.ts lines 31–65 (the contract: optional members only — grow it, never break existing fakes)
diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-06-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-06-PLAN.md
index f36be7f..d9ffd54 100644
--- a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-06-PLAN.md
+++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-06-PLAN.md
@@ -3,7 +3,7 @@ phase: 06-turn-by-turn-guidance-live-driving
plan: 06
type: execute
wave: 3
-depends_on: [06-02, 06-04, 06-05]
+depends_on: [06-02, 06-03, 06-04, 06-05]
files_modified:
- web/src/telegram/backbutton.ts
- web/src/map/follow.ts
diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-PLAN.md
index 4f968cb..6381b65 100644
--- a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-PLAN.md
+++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-PLAN.md
@@ -26,6 +26,7 @@ requirements: [GUID-05]
must_haves:
truths:
- "A Worker Cron Trigger scans guidance:* envelopes every 60 s, projects progress along the snapshot, and pushes a glyph+text turn-card on its 3-minute cadence (D-11/D-12)"
+ - "Card maneuver lines carry the STREET by DEFAULT — reconstructed by snapping the carded turn's vertex to the memoized R2 graph (snapToGraph + nameAt, per D-12's ETA/distance/street text); street-less bare-verb cards ONLY when the graph is absent in the isolate or the snap misses (D-03-style honest degradation)"
- "Cards push ONLY to envelope.chat_id read from KV — no client-supplied chat id exists anywhere in the cron path (T-06-02)"
- "The cron performs ZERO KV writes — the stateless cadence holds the 1,000-writes/day free budget (Pitfall 3)"
- "Card copy never promises live tracking — 'best-effort projection' is the strongest claim (D-13)"
@@ -33,19 +34,23 @@ must_haves:
- "The card's OPEN button deep-links ${MINI_APP_URL}/?g= so a killed webview resumes from the envelope (GUID-06 bridge)"
artifacts:
- path: "bot/src/guidance/cron.ts"
- provides: "PURE projectTurnCards + the scheduled() projection/push logic"
- exports: ["projectTurnCards", "CARD_CADENCE_S"]
+ provides: "PURE projectTurnCards + PURE makeStreetAt graph→street resolver + the scheduled() projection/push logic"
+ exports: ["projectTurnCards", "makeStreetAt", "CARD_CADENCE_S"]
- path: "bot/src/webhook.ts"
- provides: "scheduled() handler with KV list pagination"
+ provides: "scheduled() handler with KV list pagination + loadGraph-fed street reconstruction"
- path: "bot/wrangler.jsonc"
provides: "triggers.crons */1 * * * * (60 s scan; the card decision is cadence-gated in code)"
- path: "web/public/glyphs/*.png"
provides: "8 committed 256×256 turn-card PNGs served by the Worker's static assets"
key_links:
- from: "bot/src/guidance/cron.ts"
- to: "routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM)"
- via: "projection over the snapshot geometry — one turn authority shared with engine A"
- pattern: "classifyTurn"
+ to: "routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM, snapToGraph, nameAt)"
+ via: "projection + street reconstruction over shared primitives — one turn/street authority with engine A"
+ pattern: "classifyTurn|snapToGraph"
+ - from: "bot/src/webhook.ts scheduled()"
+ to: "bot/src/routing/worker-router.ts"
+ via: "loadGraph(env) — the exported isolate-memoized R2 graph feeding makeStreetAt (no second R2 loader)"
+ pattern: "loadGraph"
- from: "bot/src/webhook.ts scheduled()"
to: "bot/src/guidance/cron.ts"
via: "KV list prefix guidance: → projectTurnCards → maybe sendPhoto"
@@ -80,20 +85,38 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
geometry (polyline6), startedAtSec }, created_at } -->
+ decodePolyline6(s), deriveCumulativeDistanceM(points), snapToGraph(graph, point) →
+ { node, distanceM } | null (grid-indexed, per-graph memoized — cheap enough for a
+ one-vertex snap per carded tick), nameAt(id) on RoadGraph, haversineM.
+ Subpath-with-extension imports only. -->
-
+
+
+
@@ -134,9 +159,10 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
- bot/src/i18n/strings.ts (the BotStrings interface + copy-gate law — append keys, never inline)
- bot/test/commands.test.mjs (the bot copy gate — extend for the new keys in the same change)
- - routing/src/astar.ts classifyTurn + routing/src/polyline.ts + routing/src/geo.ts (the projection's building blocks — import, never re-implement)
+ - routing/src/astar.ts classifyTurn + routing/src/polyline.ts + routing/src/geo.ts + routing/src/snap.ts (the projection's + street reconstruction's building blocks — import, never re-implement)
+ - routing/test/fixtures/build-mini-graph.mjs + the committed mini-graph fixture (the street-present test's graph: its named way supplies the expected street string)
- bot/src/kv/guidance-sessions.ts (the frozen snapshot this projects — geometry + startedAtSec + distanceM/durationS)
- - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Turn-card send" + UI-SPEC Turn-Card Chat Message section (caption anatomy)
+ - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Turn-card send" + UI-SPEC Turn-Card Chat Message section (caption anatomy + `{action} onto {street}` line)
- progress projection: given envelope with known geometry (a synthetic 3-leg polyline with bearing changes at known points), distanceM 10000, durationS 900, startedAtSec = T: projectTurnCards(env, T+300) → progressM ≈ 10000×(300/900), remainingM ≈ 6667, etaClock = now + 600 s rounded to HH:MM
@@ -144,24 +170,27 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
- remaining distance to that turn = its along-distance − progressM, formatted "{n} M" / "{n.n} KM"
- cadence: nowSec % 180 < 60 → due true (e.g. nowSec = 3600 → true; 3660 → false); CARD_CADENCE_S = 180 exported
- progress ≥ distanceM (trip would have ended) → due false, no card (the cron never cards a dead trip)
- - caption: exactly 4 lines per the anatomy, HTML parse mode, ≤ 1024 chars, contains the honesty line verbatim, contains NO street names (street-less recorded call), register zero-padded
+ - caption: exactly 4 lines per the anatomy, HTML parse mode, ≤ 1024 chars, contains the honesty line verbatim, register zero-padded
+ - street-bearing DEFAULT (D-12): with a streetAt resolver returning a name, the maneuver line is "{dist} — {cardAction[action]} {STREET}" (the verb carries ONTO/KE; street uppercased); nameAt's unnamed id → cardStreetUnnamed ("UNNAMED ROAD"/"JALAN TANPA NAMA")
+ - street-less DEGRADATION: streetAt omitted or null for the carded vertex → "{dist} — {cardActionBare[action]}" — bare verbs, honest absence, same 4-line anatomy
+ - makeStreetAt(graph) is PURE (graph in → resolver out; snapToGraph + nameAt imported from @araharah/routing, zero I/O): against decodeGraph of the committed mini-graph fixture, a vertex on the fixture's named way resolves that way's name; a vertex off-graph → null
- copy law: caption contains none of "live", "real-time", "guaranteed" (case-insensitive) — D-13's strongest-claim gate
-1. Bot copy: append the 22 keys from the context box to `BotStrings` + both locale records (verbatim values); extend the copy gate in `bot/test/commands.test.mjs` to assert them in en + ms.
-2. RED: `bot/test/guidance-cron.test.mjs` — build a synthetic envelope (geometry via encodePolyline6 of a known 3-leg path with 90° and 45° turns), assert every behavior above with fixed nowSec values.
-3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards is PURE: injected nowSec, no KV, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number): { due: boolean; glyph: GlyphKindName | null; caption: string | null; maneuverIdx: number | null }` — decode geometry → cumulative → progressM (clamped to distanceM) → walk bearings (bearing changes between segments; skip degenerate sub-m points) → classifyTurn → first turn ≥ progressM (none → "straight" glyph, index 00) → compose caption via strings + the exact anatomy (HTML escape NOT needed: all parts are code-composed from numbers + string-table constants — assert no envelope-derived string like destinationName enters the caption in v1). GlyphKindName is cron.ts's local union mirroring the 8 PNG names. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
+1. Bot copy: append the 23 keys from the context box to `BotStrings` + both locale records (verbatim values); extend the copy gate in `bot/test/commands.test.mjs` to assert them in en + ms.
+2. RED: `bot/test/guidance-cron.test.mjs` — build a synthetic envelope (geometry via encodePolyline6 of a known 3-leg path with 90° and 45° turns), assert every behavior above with fixed nowSec values. Street cases: (a) fake resolver `streetAt = () => "JALAN SULTAN ISMAIL"` → the ONTO line composes with that street; (b) `streetAt` omitted / returns null → the bare-verb line; (c) mini-graph integration — construct the synthetic envelope geometry ALONG the mini-graph fixture's named way so the carded turn's vertex snaps deterministically onto it, run the REAL `makeStreetAt(decodeGraph(miniGraphBuffer))`, and assert the caption contains the fixture way's name.
+3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards and makeStreetAt are PURE: injected nowSec/graph, no KV, no R2, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number, streetAt?: (vertexIdx: number) => string | null): { due: boolean; glyph: GlyphKindName | null; caption: string | null; maneuverIdx: number | null }` — decode geometry → cumulative → progressM (clamped to distanceM) → walk bearings (bearing changes between segments; skip degenerate sub-m points) → classifyTurn → first turn ≥ progressM (none → "straight" glyph, index 00) → compose caption via strings + the exact anatomy: street string present → `{cardAction[action]} {street}`; null/absent resolver → `{cardActionBare[action]}`. Also export `makeStreetAt(graph: RoadGraph): (vertexIdx: number) => string | null` — snap the geometry vertex at vertexIdx (snapToGraph) → `graph.nameAt(node)`; snap miss → null; unnamed id → the cardStreetUnnamed constant. GlyphKindName is cron.ts's local union mirroring the 8 PNG names. HTML-escape surface stays ZERO: all caption parts are code-composed from numbers + string-table constants — assert no envelope-derived string (e.g. destinationName) enters the caption; street text arrives only via makeStreetAt's graph lookup. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
npm test -w bot
- - guidance-cron.test.mjs green: projection math, next-turn kind + zero-padded index, cadence table, dead-trip no-card, 4-line caption ≤ 1024, honesty-line present, no-street-name assertion, D-13 banned-words scan
- - bot/src/guidance/cron.ts imports classifyTurn from @araharah/routing (grep) — no local turn classifier
- - bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green
+ - guidance-cron.test.mjs green: projection math, next-turn kind + zero-padded index, cadence table, dead-trip no-card, 4-line caption ≤ 1024, honesty-line present, street-bearing line asserted against the mini-graph fixture (real makeStreetAt), street-less bare-verb case asserted in the graph-absent test, unnamed-id → cardStreetUnnamed, D-13 banned-words scan
+ - bot/src/guidance/cron.ts imports classifyTurn + snapToGraph from @araharah/routing (grep) — no local turn classifier, no local snapper
+ - bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardStreetUnnamed + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green
- grep `console\.` → 0 matches in bot/src/guidance/cron.ts (pure module)
- Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption are all Node-testable with zero infrastructure
+ Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption — street-bearing by default (D-12), street-less only as designed degradation — are all Node-testable with zero infrastructure
@@ -170,12 +199,15 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
- bot/src/webhook.ts (the ExportedHandler fetch skeleton — scheduled() extends the SAME object; the fail-closed secret posture lines 58–71)
- bot/src/bot/bot.ts createBot(env, { baseFetch }) (the injectable-fetch factory the offline tests capture sends through)
+ - bot/src/routing/worker-router.ts (06-04's EXPORTED loadGraph(env) memo — the ONLY R2 graph loader the cron may use)
- bot/src/config.ts (MINI_APP_URL var — the glyph URL + deep link base)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Cron config + scheduled handler" code example + Pitfall 3 (KV write budget) + local-test endpoint (`/cdn-cgi/local/scheduled`)
- scheduled() lists KV prefix "guidance:" with cursor pagination (list_complete respected), gets each envelope via getGuidanceSession (malformed → skip + `[guidance-cron] skipped: malformed` category log), projects, and for due cards calls bot.api.sendPhoto with chat_id FROM THE ENVELOPE ONLY
- - offline send capture: createBot(env, { baseFetch: recording fake }) — the recorded sendPhoto args show url `${MINI_APP_URL}/glyphs/.png`, caption matching the task-1 anatomy, parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=`
+ - street reconstruction wiring (D-12 default): scheduled() awaits loadGraph(env) ONCE per tick (the worker-router memo — no second R2 loader), and when the graph resolved passes streetAt = makeStreetAt(graph) into projectTurnCards so cards carry the street; graph null (absent/corrupt R2) → streetAt omitted → the street-less bare-verb degradation card — the send still fires (degradation, not silence; D-03 pattern)
+ - snapping is BOUNDED: only the carded turn's vertex is snapped per carded envelope (makeStreetAt called for that index, not a walk of the whole geometry) — the cron CPU tradeoff this creates is a named review item in 06-08's blocking checkpoint
+ - offline send capture: createBot(env, { baseFetch: recording fake }) — the recorded sendPhoto args show url `${MINI_APP_URL}/glyphs/.png`, caption matching the task-1 anatomy (street-bearing under the R2 mini-graph fake; bare-verb under the R2-null fake), parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=`
- non-due tick → zero sends; absent/failed envelope → skip, never throw (one bad key cannot kill the tick)
- KV-BUDGET PIN (Pitfall 3): structural scan — bot/src/guidance/cron.ts + the scheduled() arm contain ZERO `.put(` calls (stateless law; the gate lives in guidance-cron.test.mjs)
- wrangler.jsonc gains `"triggers": { "crons": ["*/1 * * * *"] }`; kv_namespaces stays exactly ONE entry (GE5)
@@ -183,21 +215,22 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
1. `bot/wrangler.jsonc`: add the triggers block with a law comment ("60 s scan; card cadence decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window; local test: curl \"http://localhost:8787/cdn-cgi/local/scheduled\"").
-2. RED: extend `bot/test/guidance-cron.test.mjs` with the scheduled() block — invoke the exported `scheduled(controller, env, ctx)` directly (controller = `{ cron: "*/1 * * * *" } as ScheduledController`), env = KV Map-fake seeded with 3 envelopes (one due, one not-due, one malformed string) + a valid BOT_TOKEN, baseFetch recording; assert the behavior list.
-3. GREEN in `bot/src/webhook.ts`: add `async scheduled(controller, env, ctx)` to the ExportedHandler — fail-closed config check (BOT_TOKEN + chat_sessions present, else category log + return); paginated KV list; per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec)` → due ? `createBot(env).api.sendPhoto(...)` inside try/catch (delivery variance is documented best-effort, D-13 — a failed send logs `[guidance-cron] send failed` and continues). ctx.waitUntil wraps the whole body. Category-only logs throughout.
+2. RED: extend `bot/test/guidance-cron.test.mjs` with the scheduled() block — invoke the exported `scheduled(controller, env, ctx)` directly (controller = `{ cron: "*/1 * * * *" } as ScheduledController`), env = KV Map-fake seeded with 3 envelopes (one due, one not-due, one malformed string) + a valid BOT_TOKEN + an R2 fake (`{ get: async () => ({ arrayBuffer: async () => miniGraphBuffer }) }` — the 06-04 webhook-test shape), baseFetch recording; assert the behavior list INCLUDING both street states (mini-graph fake → caption carries the fixture street; a second run with the R2 fake returning null → bare-verb caption, send still recorded).
+3. GREEN in `bot/src/webhook.ts`: add `async scheduled(controller, env, ctx)` to the ExportedHandler — fail-closed config check (BOT_TOKEN + chat_sessions present, else category log + return); paginated KV list; `const graph = await loadGraph(env).catch(() => null)` once per tick (import from worker-router — a corrupt/absent graph degrades to the street-less card, never fails the tick); per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec, graph ? makeStreetAt(graph) : undefined)` → due ? `createBot(env).api.sendPhoto(...)` inside try/catch (delivery variance is documented best-effort, D-13 — a failed send logs `[guidance-cron] send failed` and continues). ctx.waitUntil wraps the whole body. Category-only logs throughout.
4. Keep `npm run bot:typecheck` green (ScheduledController/ExportedHandler types from @cloudflare/workers-types).
npm test -w bot && npm run bot:typecheck
- - guidance-cron.test.mjs scheduled() block green: due-card send recorded with envelope chat_id + glyph URL + ?g= deep link; non-due → 0 sends; malformed key skipped; one-bad-key tick survives
+ - guidance-cron.test.mjs scheduled() block green: due-card send recorded with envelope chat_id + glyph URL + ?g= deep link; R2 mini-graph fake → caption carries the fixture street; R2-null fake → bare-verb street-less caption with the send still recorded; non-due → 0 sends; malformed key skipped; one-bad-key tick survives
- structural KV-budget pin: grep `\.put\(` → 0 matches in bot/src/guidance/cron.ts AND in the scheduled() arm of webhook.ts (assert in-test, PATTERNS PG style)
+ - grep `loadGraph` matches the scheduled() arm (the shared memo — no second R2 loader: grep for a second `ROAD_GRAPH.get` outside worker-router.ts → 0 matches in bot/src)
- bot/wrangler.jsonc has triggers.crons ["*/1 * * * *"]; kv_namespaces length still 1 (GE5 green)
- D-13 copy law holds in the wired path (caption from projectTurnCards, unchanged)
- bot:typecheck clean
- The cron is live in dev: scan → project → push, envelope-chat-only, zero KV writes, honest failure posture, and locally testable via the /cdn-cgi/local/scheduled endpoint
+ The cron is live in dev: scan → project → push with street-bearing cards from the shared graph memo (street-less only when the graph is absent), envelope-chat-only, zero KV writes, honest failure posture, and locally testable via the /cdn-cgi/local/scheduled endpoint
@@ -247,21 +280,22 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-02 | Spoofing / Information Disclosure (cross-chat push) | scheduled() → sendPhoto | mitigate | chat_id comes ONLY from getGuidanceSession's validated envelope (06-03's defensive read); no request path can influence the recipient; the offline test asserts the recorded send's chat_id equals the seeded envelope's. |
-| T-06-04 | Information Disclosure (logs + card copy) | cron logging + caption | mitigate | Category-only logs (`[guidance-cron] skipped: malformed`) — never chat ids, coordinates, or payloads; the card carries ETA/distance/turn-kind to the USER'S OWN chat only; the D-13 copy gate bans "live/real-time/guaranteed". |
-| — | DoS (KV budget) | scheduled() | mitigate | STATELESS: zero KV writes per tick (structural pin); reads are 1 list + N gets per minute — comfortably inside 100 k reads/day at v1 scale (A8). |
+| T-06-04 | Information Disclosure (logs + card copy) | cron logging + caption | mitigate | Category-only logs (`[guidance-cron] skipped: malformed`) — never chat ids, coordinates, or payloads; the card carries ETA/distance/street/turn-kind to the USER'S OWN chat only; street text comes from the repo's own R2 graph (OSM-derived public data), never from the envelope; the D-13 copy gate bans "live/real-time/guaranteed". |
+| — | DoS (KV budget + cron CPU) | scheduled() | mitigate / review | STATELESS: zero KV writes per tick (structural pin); reads are 1 list + N gets per minute — comfortably inside 100 k reads/day at v1 scale (A8). The street reconstruction adds 1 graph memo (1 R2 GET/cold isolate) + ONE snapToGraph per carded tick — inside the free-plan CPU budget by design, and surfaced as a NAMED ratification item in 06-08's blocking checkpoint (street-less ratification path recorded there). |
| T-06-03 | Tampering (glyph assets) | web/public/glyphs/*.png | accept | Committed repo artifacts built by a maintainer script from committed sources; the Worker serves its own repo's assets — no untrusted upload path. |
| — | Repudiation | — | accept | Cards are transactional messages to the owning chat (the Out-of-Scope table bans engagement pushes); nothing persistent beyond the TTL'd envelope. |
-- `npm test -w bot && npm run bot:typecheck` green (projection, scheduled wiring, copy gate, KV-budget pin)
+- `npm test -w bot && npm run bot:typecheck` green (projection + street reconstruction, scheduled wiring, copy gate, KV-budget pin)
+- Street states proven: mini-graph-backed caption carries the fixture street; graph-absent case produces the bare-verb honest card (both asserted in guidance-cron.test.mjs)
- `npm run glyphs` green; 8 committed PNGs; SVG sources untouched
- wrangler.jsonc: crons configured, exactly one KV namespace (GE5)
- Local cron smoke (dev): `npx wrangler dev` + `curl "http://localhost:8787/cdn-cgi/local/scheduled"` documented in the SUMMARY (requires a seeded local envelope — optional manual smoke)
-Minimized sessions get honest turn-cards: the cron scans every minute, projects along the frozen snapshot, pushes token-colored glyph cards on the 3-minute cadence to the owner's chat with an OPEN deep-link that resumes a killed webview — zero KV writes, zero dishonest claims, zero renderer dependencies.
+Minimized sessions get honest turn-cards: the cron scans every minute, projects along the frozen snapshot, and pushes token-colored glyph cards on the 3-minute cadence to the owner's chat — maneuver lines street-bearing by DEFAULT via the shared memoized graph (D-12), bare-verb street-less only as designed degradation — with an OPEN deep-link that resumes a killed webview, zero KV writes, zero dishonest claims, zero renderer dependencies.
diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
new file mode 100644
index 0000000..56a02d8
--- /dev/null
+++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
@@ -0,0 +1,551 @@
+# Phase 6: Turn-by-Turn Guidance (Live Driving) - Research
+
+**Researched:** 2026-10-24
+**Domain:** Real-time navigation state machine (client) + Worker-side routing engine A over an R2-hosted KL road graph (server) + Telegram cron turn-cards
+**Confidence:** HIGH on platform APIs and infrastructure; MEDIUM on performance envelope (free-plan CPU) and tuning constants (replay-tuned by design)
+
+## Summary
+
+Phase 6 has two interlocking workstreams. **Engine A (D-01)**: an OSM → compact road-graph build script (Overpass JSON extract → CSR typed-array binary, ~18–50 MB for KL metro — inside D-01's 30–80 MB envelope), R2-hosted, routed by a **Worker-side A\*** exposed as `/api/route`, which the web app consumes as RoutingSource backend #2 behind the Phase 5 seam. **Guidance (GUID-01..06, PX-01, PX-03)**: a pure, Node-testable client state machine (`ON_ROUTE → OFF_ROUTE → REROUTING → ON_ROUTE` per D-04) driving the banner/list/ETA/haptics surface, plus a Worker Cron Trigger that projects progress for minimized sessions and pushes glyph+text turn-cards (D-11/D-12) over the Phase 5-frozen `GuidanceSessionEnvelope`.
+
+The single most consequential platform fact: **Workers Free plan = 10 ms CPU per HTTP request and per cron invocation** [VERIFIED: developers.cloudflare.com/workers/platform/limits], with 128 MB memory. A metro-scale A\* in typed arrays plausibly fits in a few ms, but this is the phase's top technical risk and demands an explicit CPU-measurement gate (wrangler dev invocation logs) plus designed mitigations (road-class filtering keeps the graph tight; bidirectional search; single best route on reroute — alternatives only at pre-drive consult). Second platform fact: **KV free tier = 1,000 writes/day** [VERIFIED] — the cron's per-session card-cursor state must be designed against this budget (or made stateless).
+
+Two Phase 5 gates are **designed to be relaxed by this phase** (the established GE5–GE7 handoff pattern): RP1/RP2 ("Worker cannot route") are superseded by D-01 — the Worker gains `/api/route` and its first `@araharah/routing` import — and GE4–GE7 (guidance envelope types-only) are superseded when the envelope service lands. Both relaxations are that gate's documented Phase 6 responsibility, not gate violations.
+
+**Primary recommendation:** Build the guidance state machine as a pure module first (replay-tested per D-16), engine A second behind the existing RoutingSource seam, and keep every fix on-device while on-route — the only positions that ever leave the phone are reroute origins and the minimize-time envelope, preserving GEO-03's memory law and minimizing the privacy surface by construction.
+
+## User Constraints (from 06-CONTEXT.md)
+
+### Locked Decisions (verbatim intent, D-01..D-16 — do NOT re-litigate)
+
+- **D-01:** Engine A ships this phase: OSM → compact road-graph build script (KL ~30–80 MB typed arrays; one-ways + speed weights; turn restrictions deferred), graph hosted in **R2** (upload script committed — forks upload their own), **Worker-side A\*** (JS typed arrays, WASM optional) exposed as RoutingSource backend #2 via a JSON routing API on the Worker. Satisfies every law: $0, no VPS, fork-friendly, privacy.
+- **D-02:** Valhalla-to-JS port ruled infeasible; geojson-path-finder rejected (no alternatives/one-ways). Engine B (client-side router) is A's documented evolution, deferred post-v1.
+- **D-03:** Fixture backend remains #1 (golden-pairs demo); engine A is #2 and **becomes the guidance engine**. Router-pending state remains for graph-edge cases (e.g. R2 graph absent — a designed degradation, not an error).
+- **D-04:** Reroute = thresholds + grace state machine `ON_ROUTE → OFF_ROUTE → REROUTING → ON_ROUTE` (deviation > threshold with grace window + N consecutive off-route fixes before acting); storm guard: minimum time between recalculations; missed-turn detection = off-route past the maneuver point. All thresholds named constants, replay-tested.
+- **D-05:** The router snaps raw GPS fixes to the graph (engine A owns snapping — one authority). Watch fix streams raw.
+- **D-06:** Layout: top maneuver banner (distance + arrow + street; location register compacts), collapsible full maneuver list (collapsed default, expands over map, tap-out collapses), bottom guidance card (ETA + remaining + progress).
+- **D-07:** ETA smoothing = EMA + deadband — recompute from route model + snapped progress (never raw fix-to-destination distance); display updates only past ±1 min / ±5% remaining; constants replay-tested.
+- **D-08:** Haptics: `HapticFeedback.selectionChanged()` at maneuver-approach (~300 m), `impactOccurred` at the maneuver point. Graceful no-op where unsupported.
+- **D-09:** PX-03: closing during guidance requires the "abandon route?" Rams confirm card (en/ms); `disableVerticalSwipes()` while guidance active; safe-area insets respected. PX-01: BackButton walks the full graph — search → routes → guidance → back to search — never dead-ends.
+- **D-10:** Camera: locked follow (center ≈65% down-screen, pitch 35°) easing along the snapped line; user pan unlocks follow → Phase 3 recenter control returns.
+- **D-11:** Minimize during guidance → app POSTs the guidance session (envelope goes LIVE: KV writes begin, `guidance:`, 7200 s TTL). Worker Cron Trigger (60–120 s) scans active guidance keys, projects progress along the snapshot, pushes a chat turn-card when the next maneuver changes (or every N minutes). App re-open resumes foreground; cron pushes pause while foregrounded.
+- **D-12:** Turn-card = glyph + text: committed arrow-glyph icon set (8 cardinal/turn PNGs, token colors) + ETA/distance/street text — no map bitmap, no renderer dependency. Bitmap snapshots deferred (Workers cannot rasterize).
+- **D-13:** "Best-effort" is honest: cron lag, Telegram delivery variance, TTL expiry are documented behaviors; card copy never promises what the mechanism can't.
+- **D-14:** Ending a trip deletes the guidance session (KV record gone) and shows "trip ended, data deleted" (en/ms). Arrival = snapped progress ≥ end-of-route threshold; abandon = the PX-03 confirm path. Both delete.
+- **D-15:** `watchPosition` high-accuracy, app throttles by distance — guidance work runs on ≥10 m moved or ≥5 s elapsed, whichever first. D-05's gated-streaming law flips its watch ON here.
+- **D-16:** Scripted drive replays verify the state machine: recorded JSON position streams (corridor follow, off-route excursion, missed-turn excursion, GPS jitter) replayed through the machine in tests, asserting every transition, the storm guard, and ETA deadband. **Plus one real drive** as the device gate.
+
+### OpenCode's Discretion (research here, recommend)
+
+- Graph build toolchain choice (osmium/planetiler-graph/custom overpass extract), binary layout, A\* variant (bidirectional), alternatives generation via penalty runs
+- Deviation/storm/ETA constants' initial values (replay-tuned; named, not magic)
+- Cron interval within 60–120 s; "N minutes" repeat cadence; glyph icon production method (hand SVG → PNG)
+- Banner/list micro-layout within the Rams system; exact copy wording within the tone law
+- Envelope write timing (on guidance start vs first minimize — either honors the contract)
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- Engine B: fully client-side webview router
+- Turn restrictions in the graph; contraction hierarchies; CH preprocessing
+- Bitmap map snapshots in turn-cards (v1.x)
+- Voice guidance, live trip share, toll toggle, speed limits (v1.x)
+- Traffic-aware ETAs, incident-aware rerouting (v2)
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| GUID-01 | Prominent next-maneuver banner (distance + arrow + street) + collapsible full maneuver list | D-06 layout; maneuver extraction from route steps (RouteStep model already has `name`/`distanceM`); banner/list as pure builders per the route-card pattern; Telegram safe-area APIs verified (8.0+) |
+| GUID-02 | Missed-turn detection + silent auto-recalc, no asking/blaming | D-04 state machine with named constants; industry threshold anchors (OsmAnd deviation-distance setting verified; Mapbox off-route mechanism verified); engine A `/api/route` for recalc; replay tests per D-16 |
+| GUID-03 | Honest ETA + remaining distance progressing with each fix, no whiplash | D-07 EMA + deadband; `cumulativeDistanceM` day-one field (05-01) is the progress backbone; formulation + constants researched |
+| GUID-04 | Haptic cues at maneuver points | `HapticFeedback.selectionChanged()` / `impactOccurred(style)` Bot API 6.1+ [VERIFIED]; capability-guard pattern matches webapp.ts contract style |
+| GUID-05 | Minimized mid-drive → bot pushes chat turn-cards, best-effort | Cron Triggers verified (`triggers.crons`, `scheduled()`, 60 s granularity, local test endpoint); KV `list({prefix:"guidance:"})` verified; `sendPhoto` URL+caption (≤1024 chars) verified; sharp SVG→PNG verified for the 8-glyph set |
+| GUID-06 | Re-open mid-trip resumes live guidance at progressed position | Envelope read-back + `cumulativeDistanceM` projection from `startedAtSec`; resume via in-memory store (alive webview) or startParam deep-link from the turn-card; `isActive`/`activated` events (8.0+) verified |
+| PX-01 | BackButton walks search → routes → guidance → back to search, never dead-ends | BackButton API verified (6.1+); existing two-state wire (`wireBackButton`) extends to the three-state graph via the same predicate+handler-as-one-change pattern |
+| PX-03 | Safe areas respected, vertical-swipe-close disabled, "abandon route?" confirm during guidance | `disableVerticalSwipes()` Bot API 7.7+ [VERIFIED]; `safeAreaInset` 8.0+ [VERIFIED] (already in the webapp.ts contract); `enableClosingConfirmation()` 6.2+ [VERIFIED] as the only native-close hook (header swipe cannot be disabled — official docs) |
+
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Maneuver banner / list / guidance card UI | Browser | — | DOM surface; pure builders Node-testable per the route-card precedent |
+| Route progress + reroute detection (D-04 state machine) | Browser | — | Fixes never leave the device while on-route (GEO-03); pure module, replay-tested |
+| ETA smoothing (EMA + deadband) | Browser | — | Recomputes from route model + client progress; no server round-trip per fix |
+| Haptics, BackButton, swipe protection, safe areas | Browser | — | Telegram WebApp APIs live in the webview |
+| Snapping + A\* route computation (engine A) | API / Backend (Worker) | — | D-05: engine A owns snapping — one authority; `/api/route` is the JSON surface |
+| Road-graph binary build + hosting | Build tool (maintainer) → CDN/Storage (R2) | — | D-01: build script committed, forks upload their own graph to their own R2 |
+| Envelope lifecycle (mint/write/delete, `guidance:`) | API / Backend (Worker + KV) | Browser (triggers) | TTL-only KV law; chat resolved from the Bearer claim, never the body |
+| Turn-card projection + Telegram push | API / Backend (Worker cron) | — | Backgrounded webview cannot push; cron scans + projects + sends |
+| Trip end deletion + messaging | Browser (trigger) | API / Backend (delete) | D-14: both arrival and abandon paths delete the KV record |
+
+## Standard Stack
+
+### Core (all already in the repo or the platform — zero new runtime deps)
+
+| Library / Service | Version | Purpose | Why Standard |
+|-------------------|---------|---------|--------------|
+| grammY | 1.46.0 (installed) | Cron-side Telegram sends (`bot.api.sendPhoto`) | Already the bot stack; `createBot(env)` works identically inside `scheduled()` |
+| @araharah/routing | workspace (installed) | polyline6 codec, `cumulativeDistanceM`, `haversineM`, new: CSR decode + snap + A\* + `createHttpRoutingSource` | The D-01 seam — engine A plugs beneath it; bot's first workspace import (precedent: `@araharah/search` already imported by bot) |
+| maplibre-gl | 6.15.0 (installed) | Guidance camera (locked follow, pitch 35°) | Existing; follow controller + recenter semantics reused (D-10) |
+| Cloudflare R2 | platform | Graph binary hosting | D-01 binding; free tier; ranged reads available |
+| Cloudflare Cron Triggers | platform | 60–120 s turn-card projection | D-11; `*/1 * * * *` supported; local test endpoint exists |
+| Workers KV `chat_sessions` | platform (existing namespace) | `guidance:` envelopes | Phase 5-frozen contract; TTL-only law |
+| sharp | 0.35.5 [VERIFIED: npm registry] | SVG → PNG glyph build (maintainer script only) | libvips `svg` is a first-class input format [VERIFIED: sharp api-input docs]; devDependency only — runtime stays zero-dep |
+
+### Supporting
+
+| Library / Service | Version | Purpose | When to Use |
+|-------------------|---------|---------|-------------|
+| Overpass API | public instance | KL road-network extract (ways + `out geom`) | Primary build source — pure HTTP, no native deps, matches the `fetch-route-fixtures.mjs` maintainer-tool pattern |
+| osm-pbf-parser | 2.3.0 [VERIFIED: npm registry] | Pure-JS OSM PBF decode | Fallback build path if Overpass bbox response proves unwieldy (Geofabrik Malaysia extract) |
+| @resvg/resvg-js | 2.6.2 [VERIFIED: npm registry] | Alternative SVG → PNG renderer | Only if sharp's SVG rendering disappoints (native binary; sharp preferred) |
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Overpass extract (primary) | Geofabrik .osm.pbf + osmium CLI | PBF is more robust for large regions, but osmium is NOT installed locally [VERIFIED env] and adds a native-toolchain install step for every fork — Overpass is the fork-friendliest $0 path |
+| Overpass extract | planetiler / GraphHopper graph-builder | Java toolchains; produce rich graphs but far heavier than D-01's "compact typed arrays" intent and contradict turn-restrictions-deferred scope |
+| Isolate memoization of the graph (recommended) | Workers Cache (`"cache": {"enabled": true}`) | Workers Cache now works on workers.dev [VERIFIED: developers.cloudflare.com/workers/cache], BUT requests carrying an `Authorization` header auto-bypass — `/api/route` is Bearer-authenticated, so it would never hit; cron invocations are never cached; and enabling it converts normally-free static-asset requests into billed ones against the 100 k/day free quota. Skip it. |
+| Client-side ETA recompute | Server-computed ETA per fix | A server call per fix violates the memory/privacy law and burns the request budget; D-07 explicitly says recompute from the route model |
+| KV cursor key per card push | Stateless cadence-only pushes | Cursor key costs KV writes (1,000/day free [VERIFIED]); stateless is simpler; discretion area |
+
+**Installation (new devDependencies only — maintainer build surface):**
+```bash
+npm install -D -w routing sharp # glyph + graph build scripts only; zero runtime deps added
+```
+No runtime dependency changes are required: engine A is hand-rolled typed arrays (D-01), the replay fixtures are plain JSON, and cron sends ride grammY.
+
+**Version verification:** `sharp@0.35.5`, `@resvg/resvg-js@2.6.2`, `osm-pbf-parser@2.3.0` verified against the registry this session [VERIFIED: npm registry]. Installed stack verified via `npm ls`: grammy 1.46.0, maplibre-gl 6.15.0, wrangler 4.151.3, @cloudflare/workers-types 5.20261022.1 (includes R2Bucket/Cron types).
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ ┌────────────────────────── BUILD TIME (maintainer) ──────────────────────────┐
+ │ Overpass API ──► build-road-graph.mjs ──► kl-roadgraph.v1.bin (CSR) │
+ │ hand SVGs ─────► build-glyphs.mjs ──────► 8 arrow PNGs (committed) │
+ │ wrangler r2 object put ──────────► R2: araharah-graphs/… │
+ └──────────────────────────────────────────────────────────────────────────────┘
+ │ R2 binding (ROAD_GRAPH)
+ ▼
+┌─── Browser (Mini App webview) ────────────────┐ ┌─── Cloudflare Worker ────────────────────────┐
+│ watchPosition (high-accuracy) │ │ fetch(): │
+│ └─ distance throttle ≥10 m / ≥5 s (D-15) │ │ POST /api/session, /api/location (existing)│
+│ │ Fix │ │ POST /api/route ──► snap origin ─► A* ─► │
+│ ▼ │ │ JSON route (+steps) │
+│ guidance state machine (PURE, D-04) │ │ POST /api/guidance ──► mint envelope, │
+│ ON_ROUTE ► OFF_ROUTE ► REROUTING ► ON_ROUTE │ │ KV guidance: │
+│ │ (storm guard, D-04) │ │ TTL 7200 s │
+│ ├─ banner / list / card builders │ │ DELETE /api/guidance ─► KV delete (D-14) │
+│ ├─ ETA EMA + deadband (D-07) │ │ scheduled(): every 60–120 s │
+│ ├─ haptics (D-08) │ │ KV list(prefix "guidance:") │
+│ └─ camera locked-follow (D-10) │ │ per envelope: decode polyline6, │
+│ BackButton graph (PX-01) · confirm card │ │ project progress (elapsed × speed), │
+│ (PX-03) · disableVerticalSwipes · safe areas │ │ maneuver changed? ──► sendPhoto(glyph URL, │
+│ │ │ caption text) │
+│ minimize (deactivated) ──POST envelope──────┼──►│ │
+│ re-open ──GET resume / startParam───────────┼──►│ Telegram Bot API (grammY, cron side) │
+└───────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
+ │
+ ▼
+ user's chat: turn-cards
+ (glyph + ETA/distance/street)
+```
+
+Trace the primary use case: driving → throttled fixes feed the pure state machine → banner/ETA update on-device → user misses a turn → deviation crosses threshold + grace → REROUTING → `POST /api/route` (one position sent) → engine A snaps + A\* → new route snaps in silently → user minimizes → envelope POSTed → cron projects and pushes cards → re-open resumes from envelope → trip ends → DELETE → "trip ended, data deleted".
+
+### Recommended Project Structure
+
+```
+routing/
+├── src/
+│ ├── graph.ts # CSR binary decode → typed-array graph (pure)
+│ ├── snap.ts # point-to-segment snapping + progress tracking (pure)
+│ ├── astar.ts # typed-array A* (bidirectional) over the graph (pure)
+│ └── http-source.ts # createHttpRoutingSource — web's RoutingSource backend #2
+├── scripts/
+│ ├── build-road-graph.mjs # Overpass extract → CSR binary (maintainer tool)
+│ └── build-glyphs.mjs # 8 SVGs → PNGs (maintainer tool, sharp)
+└── test/ # graph/snap/astar suites + fixture mini-graph binary
+bot/
+├── src/
+│ ├── kv/guidance-sessions.ts # RELAX GE4–GE7: gains mint/get/delete (envelope shape frozen)
+│ ├── routing/worker-router.ts # env.ROAD_GRAPH load + memoize + route handler (imports @araharah/routing)
+│ └── guidance/cron.ts # scheduled() handler + PURE projectTurnCards() (Node-testable)
+├── test/guidance-cron.test.mjs # projection/push decision tests + KV write-budget pins
+└── wrangler.jsonc # +r2_buckets, +triggers.crons
+web/
+├── src/guidance/
+│ ├── guidance.ts # THE pure state machine (D-04) — replay-tested, no DOM/map imports
+│ ├── eta.ts # EMA + deadband (D-07) — pure
+│ ├── banner.ts # maneuver banner + collapsible list builders
+│ └── watch.ts # watchPosition + ≥10 m/≥5 s throttle (D-15)
+└── test/guidance-*.test.mjs # replay suites + structural gates
+web/test/fixtures/drives/ # recorded JSON position streams (D-16)
+```
+
+### Pattern 1: CSR (Compressed Sparse Row) graph in typed arrays
+
+**What:** the classic adjacency layout — one flat edge array + per-node offset array — so A\* touches zero per-node JS objects.
+**When to use:** engine A's entire data plane (D-01 mandates typed arrays).
+**Example layout** (all little-endian; zero-copy `DataView`/`TypedArray` views over the R2 `ArrayBuffer`):
+
+```
+header: magic u32 | version u32 | nodeCount u32 | edgeCount u32 | bbox 4×f64
+nodes: f64 lat[nodeCount] ++ f64 lng[nodeCount] (32 B/node)
+offsets: u32 edgeStart[nodeCount + 1] (CSR row pointers)
+edges: u32 targetNode[edgeCount] ++ u8 flags[edgeCount] (flags: bit0 oneway-reversed)
+weights: f32 seconds[edgeCount] (speed-derived travel time)
+```
+Size sanity: 500 k nodes / 700 k edges ≈ 16 MB nodes + 2.8 MB offsets + 5 MB edges+weights ≈ **~24 MB** — inside D-01's 30–80 MB envelope with headroom [ASSUMED counts; the build script's committed stats gate pins the real number]. Float32 coordinates (≈0.000 003 ° ≈ 0.3 m resolution — well under GPS noise) halve node storage if the budget tightens.
+
+### Pattern 2: typed-array A\* with a binary heap of indices
+
+**What:** open set = binary heap over `f32 key[]` + `u32 node[]` backing arrays; g-scores `f64[]` indexed by node id; closed set `u8[]`. No `Map`, no objects — allocation-free hot loop.
+**Heuristic:** haversine(toGoal) ÷ graphMaxSpeedKmh — admissible, consistent, cheap (precompute max speed at graph build).
+**Bidirectional variant** (discretion area): alternate expansion from origin- and goal-rooted searches, stop when a node is settled by both; halves explored nodes typically [ASSUMED: classic result; the replay tests assert correctness, the CPU gate asserts the win].
+**Reroute uses ONE best route** (no alternatives) — alternatives' penalty runs happen only at the pre-drive consult, where CPU headroom is not an active-drive concern.
+
+### Pattern 3: deviation state machine as a pure function (D-04/D-16)
+
+**What:** `stepGuidance(state, fix, routeModel, nowMs) → {state, effects[]}` — pure, injected clock, no DOM/map imports (the repo's Node-testability law, RS4/RS8 precedent). Replay fixtures are `Fix`-shaped JSON streams (same six fields as `web/src/location/types.ts`) pushed through the reducer; tests assert every transition, the storm guard, and the ETA deadband.
+**Named constants** (initial values, replay-tuned — discretion area):
+
+| Constant | Initial | Anchor |
+|----------|---------|--------|
+| `OFF_ROUTE_THRESHOLD_M` | 50 (accuracy-aware: `max(35, 2 × accuracyM)` capped 100) | OsmAnd ships exactly this control ("Minimal distance to recalculate route in case of deviation") [VERIFIED: osmand.net navigation-settings]; exact defaults undocumented for Mapbox/Google [ASSUMED value family 30–100 m] |
+| `OFF_ROUTE_CONSECUTIVE_FIXES` | 3 | standard GPS-jitter rejection practice [ASSUMED] |
+| `OFF_ROUTE_GRACE_MS` | 8000 | grace window before acting (D-04) [ASSUMED] |
+| `STORM_GUARD_MS` | 20000 | minimum time between recalculations (D-04) [ASSUMED] |
+| `MISSED_TURN_PAST_MANEUVER_M` | 40 | off-route past the maneuver point shortens grace (D-04) [ASSUMED] |
+| `ARRIVAL_THRESHOLD_M` | 25 | snapped progress ≥ end-of-route (D-14) [ASSUMED] |
+
+### Pattern 4: incremental snapping (client progress) vs graph snapping (server, D-05)
+
+Two distinct operations — conflating them is the classic architecture error:
+- **Client progress** (every throttled fix): project the fix onto the *selected route polyline* — walk forward from the last progress index (local window, point-to-segment in equirectangular space), read remaining distance from `cumulativeDistanceM`. No network, no graph. This drives banner/ETA/reroute-deviation.
+- **Server snap** (only when `POST /api/route` fires): engine A projects the origin onto the *graph* (coarse grid index built once per isolate) — one authority for road geometry, per D-05.
+This division means on-route fixes never leave the device; a reroute origin is the only in-drive position ever transmitted — the privacy-minimal reading of GEO-03.
+
+### Pattern 5: envelope lifecycle as a structural foreground gate (D-11)
+
+The cron pushes cards **only while an envelope exists** — the minimize→POST creates it, re-open or trip-end DELETE removes it. Foreground pausing is therefore structural (no key, no push), not a flag. Two timing options both honor the contract (D-06 discretion):
+- **Write at minimize** (D-11's literal flow): the `deactivated` event (Bot API 8.0+ [VERIFIED]) fires the POST — but a backgrounding webview may freeze before the fetch completes. Honest best-effort (D-13), plus a belt: also attempt on `visibilitychange`-style earlier signals.
+- **Write at guidance start, cron gated on a "minimized" marker**: costs extra KV writes; only worth it if the minimize-race proves unacceptable in the real drive.
+Research recommends **write at minimize** with the race documented (D-13's honesty law), reassessed at the real-drive gate.
+
+### Pattern 6: BackButton full state graph (PX-01) extends the existing wire
+
+`wireBackButton(wa, hasOverlay, walkBack)` already implements "visible iff pushed state; overlay click → walkBack (never `close()` from a pushed state); root click → close()". The graph grows from 2 to 3+ states by making `walkBack` context-aware: guidance → (abandon confirm per D-09 if exiting guidance) → routes → search. Predicate and handler ship as one change — the Phase 5 header comment explicitly reserved this extension.
+
+### Anti-Patterns to Avoid
+
+- **Per-fix server calls:** any "POST my position each fix" design — violates GEO-03's spirit, burns requests, adds latency to the hot loop.
+- **Global nearest-node snapping per fix:** O(n) scans per fix; also unstable (jumps between parallel roads). Track progress incrementally.
+- **JS-object graphs** (`{node: {edges: [...]}}`): allocation churn kills the 10 ms budget; typed arrays or nothing (D-01 mandates this anyway).
+- **Editing the frozen envelope shape:** a 5th field means drift (SessionClaim law). Any per-session state beyond the 4 fields is either a separate short-TTL key or doesn't exist.
+- **`caches.default` for the graph:** non-functional on workers.dev [VERIFIED: developers.cloudflare.com/workers/runtime-apis/cache]; the isolate memoization pattern replaces it.
+- **Alternatives generation during REROUTING:** 2–3 A\* runs inside an active-drive storm-guard window is CPU risk with zero UX need — the banner stays calm (D-04) and one good route is the product.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Great-circle distance | Custom lat/lng math per call site | `haversineM` from `@araharah/routing/src/geo.ts` (exists, tested) | One authority; the throttle (D-15) and the heuristic both need it |
+| Polyline decode for cron projection | Re-parsing per push | `routing/src/polyline.ts` codec (round-trip-proven) | bot's workspace import gets the identical codec |
+| Distance-along-line | Custom accumulation | `deriveCumulativeDistanceM` (exists) | The day-one field is already the contract for progress |
+| SVG rasterization | Canvas hacks / external web services | `sharp` (libvips SVG input) in a maintainer script | Deterministic, committed PNGs; $0; no runtime dep |
+| Telegram sends from cron | Raw `fetch("https://api.telegram.org…")` | `createBot(env).api.sendPhoto(...)` | grammY handles typing/retries; already the stack |
+| Scheduled local testing | Deploy-to-test loops | `curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=*+*+*+*+*"` [VERIFIED: cron docs] | Instant iteration in `wrangler dev` |
+| KV scan pagination | Ad-hoc key guessing | `list({prefix:"guidance:"})` + `list_complete` loop [VERIFIED: KV list docs] | Tombstone-aware pagination is documented behavior |
+
+**Key insight:** every "infrastructure-ish" primitive this phase needs (codec, geo math, camera, harness, gates) already exists from Phases 3–5. The genuinely new code is one pure state machine, one typed-array router, and one cron projection — all Node-testable without a browser or a deployed Worker.
+
+## Runtime State Inventory
+
+Omitted — this is a greenfield feature phase (no rename/refactor/migration of existing strings or records).
+
+## Common Pitfalls
+
+### Pitfall 1: Free-plan CPU ceiling vs A\* (the phase's top risk)
+**What goes wrong:** consistent >10 ms CPU invocations die with Error 1102 ("Worker exceeded resource limits") [VERIFIED: limits docs]; on the free plan this applies per HTTP request AND per cron tick.
+**Why it happens:** A\* over a too-large graph (unfiltered highway=\* includes footways/service roads), per-node object allocation, or recomputing the graph-grid index per request.
+**How to avoid:** filter road classes at build (drivable classes only); typed-array everything; build the snap-grid lazily once per isolate; measure early — a dedicated task runs a real route through `wrangler dev` and reads the invocation CPU time; the budget gate belongs in the plan, not in hope.
+**Warning signs:** `exceededCpu` in invocation logs; slow route responses in dev (~>200 ms wall on cold isolate).
+
+### Pitfall 2: The minimize race on the envelope POST
+**What goes wrong:** the app tries to POST the envelope during `deactivated`, but the webview's JS is frozen before the fetch completes → no envelope → no cards (GUID-05 silently absent).
+**Why it happens:** mobile webviews suspend timers/network on backgrounding non-deterministically.
+**How to avoid:** fire the POST on the earliest available signal; treat failure as documented best-effort (D-13); optionally also write at guidance start (discretion allows) so the worst case is "cards begin one cadence late".
+**Warning signs:** real-drive test shows minimize→no-card gaps; bot-side KV list empty while user reports driving.
+
+### Pitfall 3: Cron cardinality vs the KV write budget
+**What goes wrong:** per-session cursor writes each tick (every 60 s) exhaust 1,000 writes/day [VERIFIED] after ~16 session-hours; or same-key writes violate the 1 write/s/key limit.
+**How to avoid:** stateless cadence ("push every N minutes when maneuver changed by projection" derived from wall clock, no cursor), or cursor with a coarse write rate; pin the budget with a bot-side gate like PG1.
+**Warning signs:** KV write metrics climbing per cron tick; 429-class KV behavior.
+
+### Pitfall 4: `disableVerticalSwipes()` is not close protection
+**What goes wrong:** assuming disabling vertical swipes prevents app closure during guidance.
+**Why it happens:** official docs state the user can ALWAYS minimize/close by swiping the app's *header* regardless [VERIFIED: core.telegram.org/bots/webapps, `isVerticalSwipesEnabled`].
+**How to avoid:** layer defenses — `disableVerticalSwipes()` (7.7+) kills body-swipe minimize; `enableClosingConfirmation()` (6.2+) puts a native confirm on the close path; the D-09 Rams card guards in-app exits. Research recommends enabling the native closing confirmation during guidance as defense-in-depth (planner decision).
+**Warning signs:** device test closes the app from the header with no confirm.
+
+### Pitfall 5: Version-gating Telegram APIs
+**What goes wrong:** `HapticFeedback`/`disableVerticalSwipes`/`safeAreaInset` calls throw or no-op silently on older clients.
+**Why it happens:** features span Bot API 6.1 → 8.0+; Telegram clients in the wild lag.
+**How to avoid:** capability-guard exactly like the existing contract: optional members on `TelegramWebAppLike` + `isVersionAtLeast("7.7")` checks + `?.` chains; haptics are graceful no-ops by design (D-08).
+**Warning signs:** TS forcing non-optional members onto the fake; exceptions on the Android device matrix.
+
+### Pitfall 6: Reroute storm after one excursion
+**What goes wrong:** each fresh fix re-triggers REROUTING (dense urban canyon near parallel roads).
+**Why it happens:** missing grace/consecutive-fix/storm-guard constants, or deviation measured against the OLD route after a successful reroute.
+**How to avoid:** implement the full D-04 machine (threshold + grace + N fixes + storm guard) with the constants table above; after ON_ROUTE, deviation is measured against the NEW route only; replay tests assert the storm guard explicitly (D-16).
+**Warning signs:** >1 `/api/route` call inside STORM_GUARD_MS in replays.
+
+### Pitfall 7: RP1/RP2 and GE4–GE7 gates reddening "unexpectedly"
+**What goes wrong:** Phase 6's first bot-side routing import fails `bot:test` because 05-05's RP gates ban `/api/route` and `@araharah/routing` in bot/src; GE4–GE7 ban the envelope service.
+**Why it happens:** those gates were designed as negative-space guards with documented Phase 6 handoffs.
+**How to avoid:** the plans that add these surfaces re-pin the gates in the same change (the C6/L3/GE handoff precedent) — RP1/RP2 shrink to ban *unauthenticated* or *proxy-shaped* routing; GE5/GE6/GE7 re-pin to "exactly two KV namespaces", "Env has guidance fields", "service exists".
+**Warning signs:** RED suites at phase start — expected, and each gate's doc-comment names the owning phase.
+
+### Pitfall 8: Equirectangular math near the equator vs meters
+**What goes wrong:** point-to-segment projection using raw degree deltas produces distorted distances in lng.
+**How to avoid:** scale lng by `cos(lat)` (KL at ~3.1° N — error negligible) or convert via local-meter approximation; all displayed distances route through `cumulativeDistanceM`/`haversineM`, never raw Δdegrees.
+
+## Code Examples
+
+### Cron config + scheduled handler (wrangler.jsonc + Worker)
+```jsonc
+// bot/wrangler.jsonc — additions (existing keys unchanged)
+{
+ "r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
+ "triggers": { "crons": ["*/1 * * * *"] } // 60 s cadence; app-side logic decides whether a card is due (D-11: 60–120 s)
+}
+```
+```typescript
+// webhook.ts — extend the existing ExportedHandler (both handlers on ONE worker)
+export default {
+ async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { /* existing + /api/route + /api/guidance */ },
+ async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise {
+ // scan → project → push; category-only logs (D-14)
+ let cursor: string | undefined;
+ do {
+ const page = await env.chat_sessions.list({ prefix: "guidance:", cursor });
+ for (const key of page.keys) { /* get → projectTurnCards → maybe sendPhoto */ }
+ cursor = page.list_complete ? undefined : page.cursor;
+ } while (cursor !== undefined);
+ },
+} satisfies ExportedHandler;
+```
+Sources: [VERIFIED: developers.cloudflare.com/workers/configuration/cron-triggers, /r2/api/workers/workers-api-reference, /kv/api/list-keys]
+
+### R2 graph load + isolate memoization (the cache that works)
+```typescript
+// bot/src/routing/worker-router.ts
+let graphP: Promise | null = null; // module scope = per-isolate memo
+function loadGraph(env: Env): Promise {
+ graphP ??= env.ROAD_GRAPH.get(GRAPH_KEY) // 1 R2 GET per cold isolate (I/O, not CPU)
+ .then(o => o === null ? null : decodeGraph(await o.arrayBuffer()));
+ return graphP;
+}
+```
+Ranged reads (`get(key, { range: { offset, length } })`) exist if sub-graph streaming is ever needed [VERIFIED: R2 API reference].
+
+### Telegram capability guards (extending the hand-rolled contract)
+```typescript
+// TelegramWebAppLike gains OPTIONAL members (the contract stays the contract):
+HapticFeedback?: { impactOccurred(style: "light" | "medium" | "heavy" | "rigid" | "soft"): unknown;
+ notificationOccurred(type: "error" | "success" | "warning"): unknown;
+ selectionChanged(): unknown };
+disableVerticalSwipes?: () => void; // Bot API 7.7+
+enableVerticalSwipes?: () => void;
+enableClosingConfirmation?: () => void; // Bot API 6.2+
+isActive?: boolean; // Bot API 8.0+
+// events: "activated" | "deactivated" via existing onEvent?(event, cb)
+```
+All names/versions verified against the official page this session [VERIFIED: core.telegram.org/bots/webapps].
+
+### Distance throttle (D-15) reusing the workspace geo module
+```typescript
+import { haversineM } from "@araharah/routing/src/geo.ts";
+const moved = last === null ? Infinity : haversineM(last, fix);
+if (moved >= 10 || fix.ts - lastAcceptedTs >= 5000) accept(fix); // ≥10 m moved OR ≥5 s elapsed
+```
+The web platform has NO native distance filter — `watchPosition` options are only `enableHighAccuracy/timeout/maximumAge` [VERIFIED: MDN]; the throttle is app-side by design.
+
+### Turn-card send (cron side)
+```typescript
+await bot.api.sendPhoto(chat_id, `${MINI_APP_URL}/glyphs/${turnGlyph}.png`, {
+ caption, // ≤ 1024 chars [VERIFIED: Bot API sendPhoto]
+ parse_mode: "HTML",
+});
+```
+`sendPhoto` accepts a public HTTPS URL (Telegram fetches it) — glyphs ride the Worker's existing static assets [VERIFIED: Bot API via gramio mirror of core.telegram.org/bots/api].
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Cache API on custom domains only | Workers Cache (`"cache": {"enabled": true}`) works on workers.dev, tiered, request-collapsing | Docs current as of Oct 2026 | Not needed this phase (Bearer auto-bypass + cron uncached); isolate memoization remains correct |
+| Workers CPU 30 s (paid) / 10 ms (free) | Unchanged, but cron wall time documented at 15 min; CPU free = 10 ms/tick | limits doc updated 2026-10-12 | Confirms the A\* design constraint |
+| Bot API ~7.x assumed in training | Bot API 10.1 (June 2026); Mini Apps gained `isActive`, fullscreen, safe areas, LocationManager at 8.0 (Nov 2024) | core.telegram.org changelog | All Phase 6 APIs (6.1/6.2/7.7/8.0) are years-stable; `isActive`/`deactivated` give clean minimize detection |
+| `@types/telegram-web-app` dependency | Hand-rolled `TelegramWebAppLike` contract | Project decision (Phase 3) | Grows by optional members only; fakes stay cheap |
+
+**Deprecated/outdated:**
+- Cloudflare Pages (already superseded project-wide; nothing here touches it)
+- `node-osmium` npm bindings (abandoned upstream — do not use; CLI osmium or pure-JS parsing instead)
+- Unbound/Bundled Workers plans (deprecated platform-side; irrelevant to a new free-plan Worker)
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | KL-metro A\* fits the 10 ms free-plan CPU budget with class-filtered CSR + typed arrays | Pitfall 1 / Standard Stack | Reroute calls fail 1102 → mitigations: shrink graph (drop minor classes), bidirectional A\*, or document Workers Paid ($5/mo, breaks $0 law) as an explicit fork decision. CPU-measurement task mandatory before wide rollout. |
+| A2 | KL drivable graph ≈ 300–800 k nodes / ≤1 M edges → ~18–50 MB binary | Pattern 1 | If far larger, binary exceeds 30–80 MB envelope → tighten class filter / Float32 coords / split tiles; build script's stats gate catches it immediately |
+| A3 | R2 free tier ≈ 10 GB storage, 1 M Class-A + 10 M Class-B ops/month | Standard Stack | If changed, upload/read pattern still $0 at this project's scale; verify at bucket-creation task |
+| A4 | Reroute threshold family (50 m / 3 fixes / 8 s grace / 20 s storm) are sane starting constants | Pattern 3 | Constants are replay-tuned by design (D-04); wrong initial values cost iteration time only |
+| A5 | EMA α ≈ 0.25/fix smooths adequately with the locked ±1 min/±5% deadband | D-07 | Same replay-tuning story as A4 |
+| A6 | Backgrounded webviews stop receiving `watchPosition` callbacks (making cron cards the right mechanism) | Architecture | If fixes continued, cron projection would double-count; harmless (envelope carries no live fix), but the D-11 framing already assumes it |
+| A7 | Overpass `out geom` for the KL bbox returns ~50–300 MB JSON within timeout=300 | Don't Hand-Roll / build script | Fallback path documented: Geofabrik extract + osm-pbf-parser (pure JS); worst case split the bbox into 2–4 queries |
+| A8 | KV write budget (1,000/day free) suffices with stateless or coarse cursor pushes at v1 scale | Pitfall 3 | Budget gate pin + stateless fallback; if v1 traffic grows, this surfaces as a planned-purchase decision, not a redesign |
+| A9 | `watchPosition` fix cadence in Android WebView ≈ 1 Hz under `enableHighAccuracy` | Pattern 4 | Only affects tuning feel; the ≥10 m/≥5 s throttle normalizes any cadence |
+
+## Open Questions (RESOLVED)
+
+1. **Resume path when the webview was KILLED (not just minimized)**
+ - What we know: alive-minimized webview resumes from its memory-held session; a killed one boots fresh with no guidance id. The frozen envelope is keyed by opaque `guidance:`; KV cannot be queried by chat_id value.
+ - What's unclear: whether the turn-card's OPEN deep-link should carry `startapp=` (startParam is already in the contract; Bot API direct-link `startapp` verified) so a fresh boot can GET and resume.
+ - Recommendation: yes — card button = Mini App URL + `?g=`/startapp param; fresh boot reads it, fetches the envelope, resumes. No extra KV keys; privacy-clean (id is opaque, TTL-bounded). Planner picks the exact knob name.
+ - RESOLVED: the `?g=` knob ships — 06-07's card button carries `/?g=` (web_app url) and 06-06 task 3 reads it at boot (UUID-validate → GET → RESUMING state).
+2. **Native closing confirmation during guidance**
+ - What we know: the header swipe cannot be disabled (official docs); `enableClosingConfirmation()` is the only native hook; D-09's Rams card covers in-app exits.
+ - Recommendation: enable it during guidance as defense-in-depth; the Rams card remains the designed surface. Flag for UI-SPEC.
+ - RESOLVED: engaged during guidance as defense-in-depth, version-gated 6.2+ — 06-05 task 4 (close-protection.ts engage/release); the Rams card remains the designed surface (UI-SPEC records the layering).
+3. **Cron push state: cursor key vs stateless cadence**
+ - What we know: 1,000 KV writes/day free; cursor writes cost 1/push; stateless cadence may repeat or skip cards around maneuver boundaries.
+ - Recommendation: start stateless ("push when projected next-maneuver index differs from previous tick's *computed* index" — derivable without state by re-deriving from wall clock? No — needs state; so: fixed cadence N=3 min). Reassess at the real drive. Planner decides within discretion.
+ - RESOLVED: stateless cadence — `CARD_CADENCE_S = 180`, `due = nowSec % 180 < 60`, zero KV writes — 06-07 task 1 (cron.ts); reassessment happens at the real drive (06-08 Part B).
+4. **Overpass bbox exact extent**
+ - What we know: must cover the gazetteer's KL-metro region (KL_CENTER [101.6958, 3.1513] + fixture pairs to IOI City Mall in the south).
+ - Recommendation: derive the bbox from the committed corpus + gazetteer extremes + margin, pinned as a named constant with the build's stats gate; keeps tuning mechanical.
+ - RESOLVED: bbox computed at build time from the golden corpus pairs + gazetteer extremes + margin, printed by the stats gate — 06-04 task 1 (build-road-graph.mjs).
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node | all build/test | ✓ | 24.18.0 | — |
+| npm workspaces | workspace scripts | ✓ | 11.16.0 | — |
+| wrangler | R2 bucket, cron config, dev, deploy | ✓ | 4.151.3 | — |
+| @cloudflare/workers-types | R2Bucket/ScheduledController types | ✓ | 5.20261022.1 | — |
+| playwright | capture legs (device-gate prep) | ✓ | 1.64.1 | — |
+| osmium CLI | PBF graph build (fallback path) | ✗ | — | Overpass JSON primary (no install); osm-pbf-parser npm as second fallback |
+| inkscape / rsvg-convert | SVG → PNG | ✗ | — | sharp 0.35.5 (npm, libvips SVG input) [VERIFIED] |
+| R2 bucket `araharah-graphs` | graph hosting | ✗ (not yet created) | — | `npx wrangler r2 bucket create` — first-time setup task (R2 on the account: verify free-tier entitlement at setup) |
+| Cron trigger | turn-cards | ✗ (not yet configured) | — | wrangler.jsonc + deploy; 15 min propagation noted [VERIFIED] |
+| Node under nvm | every shell command | ✓ | — | `source ~/.nvm/nvm.sh` first (all 20 prior plans' precedent) |
+
+**Missing dependencies with no fallback:** none — every gap has a documented setup task or fallback.
+**Missing dependencies with fallback:** osmium (→ Overpass primary), rasterizers (→ sharp), R2/cron provisioning (→ setup tasks in-plan, fork docs per D-01).
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Hand-rolled zero-dep runners (established Phases 1–5): `web/test/runner.mjs`, `bot/test/runner.mjs`, `routing/test/runner.mjs` over `harness.mjs` registries |
+| Config file | none — auto-discovers `*.test.mjs` per workspace |
+| Quick run command | `npm test -w web` / `npm test -w bot` / `npm run routing:test` |
+| Full suite command | `npm run web:test && npm run bot:test && npm run routing:test` (+ `npm run web:capture` for legs, `npx wrangler dev` + `/cdn-cgi/local/scheduled` for cron integration) |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| GUID-01 | Banner (distance+arrow+street) renders; list collapses/expands | unit (builders, fake DOM) + capture leg | `npm test -w web` (guidance-surface suite) | ❌ Wave 0 (`web/test/guidance-surface.test.mjs`) |
+| GUID-02 | OFF_ROUTE→REROUTING→ON_ROUTE transitions; storm guard; no re-route spam | replay (pure state machine, JSON drives) | `npm test -w web` (replay suite) + real-drive device gate | ❌ Wave 0 (`web/test/guidance-replay.test.mjs`, `web/test/fixtures/drives/*.json`) |
+| GUID-03 | ETA/remaining progress; EMA + deadband holds under jitter | replay | `npm test -w web` (eta suite inside replay fixtures) | ❌ Wave 0 (`web/test/guidance-eta.test.mjs`) |
+| GUID-04 | Haptics fire at approach/maneuver; no-op when unsupported | structural + capability-guard unit | `npm test -w web` | ❌ Wave 0 (inside guidance-surface suite) — real feel only at device gate |
+| GUID-05 | Cron projects progress; pushes on maneuver change; pauses foreground (structural: no envelope = no push) | unit (pure `projectTurnCards`) + wrangler-dev cron endpoint + KV budget pins | `npm test -w bot` + `curl "http://localhost:8787/cdn-cgi/local/scheduled"` | ❌ Wave 0 (`bot/test/guidance-cron.test.mjs`) |
+| GUID-06 | Resume at progressed position from envelope | replay + unit (projection math) | `npm test -w web` | ❌ Wave 0 (replay fixture: minimize→resume scenario) |
+| PX-01 | BackButton walks search→routes→guidance→search; never dead-ends | behavioral (fake WebApp) | `npm test -w web` (backbutton-graph suite) | ❌ Wave 0 (extend existing backbutton coverage) |
+| PX-03 | disableVerticalSwipes on guidance; confirm card gates exit; safe-area padding applied | structural + unit | `npm test -w web` | ❌ Wave 0 (inside guidance-surface suite) — device gate covers real swipes |
+| Engine A (D-01/D-03) | CSR decode round-trip; snap correctness; A\* equals brute-force Dijkstra on a fixture mini-graph; honest null when graph absent | unit | `npm run routing:test` | ❌ Wave 0 (`routing/test/graph.test.mjs`, `astar.test.mjs`, `snap.test.mjs` + committed mini-graph fixture) |
+| Engine A perf | A\* CPU within budget on realistic subgraph; `/api/route` wall time sane | integration (wrangler dev invocation CPU + timing) | `npx wrangler dev` + scripted route calls; read invocation CPU logs | ❌ Wave 0 (script; measurement printed per BA9 "budget gates print their measurement" pattern) |
+| D-14 | Trip end deletes envelope; both arrival and abandon paths | unit (KV fake) + bot gates | `npm test -w bot` | ❌ Wave 0 (guidance-envelope service suite; GE4–GE7 re-pins) |
+| PX-03/GUID-02 device gates | one real drive: banner legibility, reroute self-heal, resume, trip-end messaging | manual-only (device gate — the phase's declared protocol, per D-16 and the phase 3/4 precedent) | human drive with capture checklist | ❌ device-gate doc (phase 6 exit) |
+
+### Sampling Rate
+- **Per task commit:** the touched workspace's quick suite (`npm test -w web|bot` / `npm run routing:test`)
+- **Per wave merge:** full three-workspace suite + `npm run web:typecheck` / `bot:typecheck` / `routing:typecheck` (+ theme audit when CSS touched)
+- **Phase gate:** all suites green + capture legs (extended with guidance legs) + the one real drive + A\* CPU measurement printed and inside budget, before `/gsd-verify-work`
+
+### Wave 0 Gaps
+- [ ] `routing/test/` graph/astar/snap suites + committed mini-graph binary fixture (hand-built tiny CSR — also the replay ground truth)
+- [ ] `web/test/fixtures/drives/` — four recorded streams per D-16 (corridor follow, off-route excursion, missed-turn excursion, GPS jitter)
+- [ ] `web/test/guidance-{replay,eta,surface}.test.mjs` + `bot/test/guidance-cron.test.mjs`
+- [ ] Gate re-pin tasks: RP1/RP2 (bot route surface lands), GE4–GE7 (envelope service lands)
+- [ ] Setup tasks: `wrangler r2 bucket create` + upload script + fork docs; `triggers.crons` + glyph assets in static dir; `npm install -D -w routing sharp` for build scripts
+- [ ] BA9 budget extension to cover `web/src/guidance/` (+ whatever the bundle law names)
+
+*(Framework install: none needed — the harness family already exists.)*
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes (Worker endpoints) | Existing initData→Bearer session gate reused for `/api/route`, `/api/guidance`; cron authenticates to Telegram via BOT_TOKEN secret only |
+| V3 Session Management | yes | One-time opaque tokens (existing mintSession rotation); envelope carries NO token — chat_id resolved server-side from the claim |
+| V4 Access Control | yes — the phase's core server surface | `/api/guidance` derives chat ONLY from the Bearer claim (the `/api/location` precedent, T-03-01); cron sends ONLY to envelope.chat_id read from KV; no client-supplied chat ids anywhere |
+| V5 Input Validation | yes | Origin lat/lng validated + clamped to the graph bbox before A\* (reject non-numeric/out-of-range — 400 category); route/guidance request bodies size-capped (MAX_INITDATA_CHARS pattern); no zod — hand validation per repo pattern |
+| V6 Cryptography | no new | No new crypto; session ids remain `crypto.randomUUID()` (T-02-05 precedent) |
+| V7/V9 (logging/errors) | yes | D-14 category-only logging extended to cron + routing paths — never coordinates, chat ids, or payloads in logs |
+
+### Known Threat Patterns for {Cloudflare Workers + KV/R2 + Telegram}
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Unauthenticated routing oracle (anyone hammering /api/route for free routing) | DoS / Elevation | Bearer-gate `/api/route` like `/api/location`; CPU budget per request is the natural rate limiter on free plan |
+| Envelope injection / cross-chat card push (client posts someone else's chat_id) | Tampering / Spoofing | chat_id from claim only; envelope minted server-side; randomUUID ids |
+| Graph binary tampering (fork uploads malicious/absent graph) | Tampering | Graph absence = honest router-pending (D-03, designed); upload script is maintainer-surface; header magic+version+size check on decode, fail-honest |
+| Privacy leak via cron logs or card copy | Information Disclosure | Cards carry ETA/distance/street to the USER'S OWN chat only; logs category-only (D-14); envelope TTL 7200 s + D-14 active delete |
+| Origin leakage while on-route (continuous tracking) | Information Disclosure | On-route fixes never transmitted (Pattern 4 division of labor); only reroute origins + minimize-time envelope leave the device |
+
+## Sources
+
+### Primary (HIGH confidence)
+- developers.cloudflare.com/workers/platform/limits — CPU 10 ms free/5 min paid; cron CPU 10 ms free/30 s paid; 128 MB memory; cron wall 15 min; KV/R2 subrequest accounting (fetched 2026-10-24)
+- developers.cloudflare.com/workers/configuration/cron-triggers — triggers.crons config, scheduled() signature, `*/1 * * * *`, `/cdn-cgi/local/scheduled` local testing, 15-min propagation
+- developers.cloudflare.com/r2/api/workers/workers-api-reference — r2_buckets binding, get/arrayBuffer, range reads, list
+- developers.cloudflare.com/kv/platform/limits — 100 k reads/day, 1,000 writes/day free, 1 write/s/key, 25 MiB values, 1,000 ops/invocation
+- developers.cloudflare.com/kv/api/list-keys — list({prefix,cursor}), list_complete, tombstone caveat
+- developers.cloudflare.com/workers/cache + /workers/runtime-apis/cache — classic Cache API non-functional on workers.dev; new Workers Cache: workers.dev OK but Authorization auto-bypass, cron never cached
+- core.telegram.org/bots/webapps — HapticFeedback (6.1+), BackButton (6.1+), disableVerticalSwipes (7.7+), enableClosingConfirmation (6.2+), safeAreaInset (8.0+), isActive/activated/deactivated (8.0+), header-swipe-cannot-be-disabled note
+- developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition — options surface (no distance filter)
+- osmand.net/docs/user/navigation/guidance/navigation-settings — "Minimal distance to recalculate route" control
+- docs.mapbox.com/android/navigation/guides/turn-by-turn-navigation/rerouting-and-refresh — off-route observer → auto-reroute mechanism
+- sharp docs (api-input) — SVG first-class input format; npm registry — sharp 0.35.5, @resvg/resvg-js 2.6.2, osm-pbf-parser 2.3.0
+
+### Secondary (MEDIUM confidence)
+- Telegram Bot API sendPhoto semantics (caption ≤1024 chars, photo-by-URL) — core.telegram.org/bots/api content mirrored via gramio.dev + shapeless.ai spec sheets, cross-agreeing
+- wiki.openstreetmap.org/Overpass_API + gis.stackexchange — `out geom`, bbox query patterns for road extraction
+
+### Tertiary (LOW confidence — flagged for validation)
+- Mapbox/Google specific off-route default values (undocumented publicly; ASSUMED family values only)
+- Background webview geolocation suspension specifics per OS (A6)
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — every platform API and version verified against official docs/registry this session
+- Architecture: HIGH for boundaries (seams all exist from Phases 3–5; division of labor is forced by privacy law), MEDIUM for cron/envelope lifecycle edge (minimize race, documented best-effort per D-13)
+- Pitfalls: HIGH on platform limits and gate re-pins (verified/documentable), MEDIUM on performance constants (A1/A4 — measurement tasks mandated)
+- Validation: HIGH — replay design directly extends the established hand-rolled harness; device-gate protocol inherited from phases 3–5
+
+**Research date:** 2026-10-24
+**Valid until:** 2026-11-23 (platform docs current as of Oct 2026; re-verify Workers limits + Telegram changelog if planning slips past 30 days)
diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-VALIDATION.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-VALIDATION.md
new file mode 100644
index 0000000..629f0d9
--- /dev/null
+++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-VALIDATION.md
@@ -0,0 +1,87 @@
+---
+phase: 6
+slug: turn-by-turn-guidance-live-driving
+status: draft
+nyquist_compliant: false
+wave_0_complete: false
+created: 2026-10-24
+---
+
+# Phase 6 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Hand-rolled zero-dep runners (established Phases 1–5): `web/test/runner.mjs`, `bot/test/runner.mjs`, `routing/test/runner.mjs` over `harness.mjs` registries |
+| **Config file** | none — auto-discovers `*.test.mjs` per workspace |
+| **Quick run command** | `npm test -w web` / `npm test -w bot` / `npm run routing:test` |
+| **Full suite command** | `npm run web:test && npm run bot:test && npm run routing:test` (+ `npm run web:capture` for legs, `npx wrangler dev` + `/cdn-cgi/local/scheduled` for cron integration) |
+| **Estimated runtime** | ~30 seconds (unit/replay suites); cron integration adds ~60 s (wrangler dev boot) |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run the touched workspace's quick suite (`npm test -w web` / `npm test -w bot` / `npm run routing:test`)
+- **After every plan wave:** Run `npm run web:test && npm run bot:test && npm run routing:test` + typechecks (`web:typecheck` / `bot:typecheck` / `routing:typecheck`) + theme audit when CSS touched
+- **Before `/gsd-verify-work`:** Full suite green + capture legs (extended with guidance legs) + the one real drive + A\* CPU measurement printed and inside budget
+- **Max feedback latency:** 60 seconds
+
+---
+
+## Per-task Verification Map
+
+| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
+| (assigned at plan time) | 01 | 0 | Engine A (D-01/D-03) | T-06-03 | graph header magic+version+size check on decode, fail-honest | unit | `npm run routing:test` | ❌ W0 (`routing/test/graph.test.mjs`, `astar.test.mjs`, `snap.test.mjs` + committed mini-graph fixture) | ⬜ pending |
+| (assigned at plan time) | — | — | GUID-01 | — | N/A | unit + capture leg | `npm test -w web` (guidance-surface suite) | ❌ W0 (`web/test/guidance-surface.test.mjs`) | ⬜ pending |
+| (assigned at plan time) | — | — | GUID-02 | — | N/A | replay (pure state machine, JSON drives) | `npm test -w web` (replay suite) + real-drive device gate | ❌ W0 (`web/test/guidance-replay.test.mjs`, `web/test/fixtures/drives/*.json`) | ⬜ pending |
+| (assigned at plan time) | — | — | GUID-03 | — | N/A | replay | `npm test -w web` (eta suite inside replay fixtures) | ❌ W0 (`web/test/guidance-eta.test.mjs`) | ⬜ pending |
+| (assigned at plan time) | — | — | GUID-04 | — | capability-guard no-op when unsupported | structural + unit | `npm test -w web` | ❌ W0 (inside guidance-surface suite) | ⬜ pending |
+| (assigned at plan time) | — | — | GUID-05 | T-06-02 / T-06-04 | cron sends ONLY to envelope.chat_id from KV; no client-supplied chat ids | unit + wrangler-dev cron endpoint + KV budget pins | `npm test -w bot` + `curl "http://localhost:8787/cdn-cgi/local/scheduled"` | ❌ W0 (`bot/test/guidance-cron.test.mjs`) | ⬜ pending |
+| (assigned at plan time) | 06 | 3 | GUID-06 | — | N/A | behavioral (route-surface suite) + unit (cron projection math) | `npm test -w web` + `npm test -w bot` | ❌ W0 (06-06 task 3: ?g= resume boot + envelope POST/DELETE flows in `web/test/route-surface.test.mjs`; projection in `bot/test/guidance-cron.test.mjs` — 06-07) | ⬜ pending |
+| (assigned at plan time) | — | — | PX-01 | — | N/A | behavioral (fake WebApp) | `npm test -w web` (backbutton-graph suite) | ❌ W0 (extend existing backbutton coverage) | ⬜ pending |
+| (assigned at plan time) | — | — | PX-03 | — | N/A | structural + unit | `npm test -w web` | ❌ W0 (inside guidance-surface suite) | ⬜ pending |
+| (assigned at plan time) | — | — | Engine A perf (A1/A4) | T-06-01 | Bearer-gated `/api/route`; CPU budget per request is the natural rate limiter | integration (wrangler dev CPU + timing) | `npx wrangler dev` + scripted route calls; read invocation CPU logs | ❌ W0 (measurement script; prints its measurement) | ⬜ pending |
+| (assigned at plan time) | — | — | D-14 (trip end) | T-06-04 | envelope deleted on arrival AND abandon; TTL 7200 s | unit (KV fake) + bot gates | `npm test -w bot` | ❌ W0 (guidance-envelope service suite; GE4–GE7 re-pins) | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `routing/test/` graph/astar/snap suites + committed mini-graph binary fixture (hand-built tiny CSR — also the replay ground truth)
+- [ ] `web/test/fixtures/drives/` — four recorded streams per D-16 (corridor follow, off-route excursion, missed-turn excursion, GPS jitter)
+- [ ] `web/test/guidance-{replay,eta,surface}.test.mjs` + `bot/test/guidance-cron.test.mjs`
+- [ ] Gate re-pin tasks: RP1/RP2 (bot route surface lands), GE4–GE7 (envelope service lands)
+- [ ] Setup tasks: `wrangler r2 bucket create` + upload script + fork docs; `triggers.crons` + glyph assets in static dir; `npm install -D -w routing sharp` for build scripts
+- [ ] BA9 budget extension to cover `web/src/guidance/` (+ whatever the bundle law names)
+
+*(Framework install: none needed — the harness family already exists.)*
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| Real drive: banner legibility in sunlight, reroute self-heal on actual missed turn, resume after minimize, trip-end messaging | GUID-02 / PX-03 device gate | Physical GPS + real road geometry cannot be simulated; the phase's declared protocol (D-16, phases 3–5 precedent) | One real drive with capture checklist: start route, miss a turn deliberately, minimize mid-drive, re-open, end trip |
+
+---
+
+## Validation Sign-Off
+
+- [ ] All tasks have `` verify or Wave 0 dependencies
+- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
+- [ ] Wave 0 covers all MISSING references
+- [ ] No watch-mode flags
+- [ ] Feedback latency < 60 s
+- [ ] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** pending