fix(06): revise plans per checker feedback — D-12 street-bearing cards by default, split 06-05 task 2, dep/wording/doc fixes

This commit is contained in:
araharah-executor
2026-10-24 08:06:42 +08:00
parent 7fe8c6aa65
commit 4212d1acd0
7 changed files with 761 additions and 59 deletions
@@ -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[] -->
<behavior>
- `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)
</behavior>
<action>
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.
</action>
<verify>
@@ -173,7 +175,7 @@ Then RUN it (network fetch, minutes) and confirm the stats gate passes; record t
</behavior>
<action>
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<RoadGraph | null> | 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<Response>`: 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<RoadGraph | null> | 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<Response>`: 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).
</action>
@@ -182,7 +184,7 @@ Then RUN it (network fetch, minutes) and confirm the stats gate passes; record t
</verify>
<acceptance_criteria>
- 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
<verification>
- `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
</verification>
@@ -167,12 +167,11 @@ Output: glyphs + 5 builders + 2 guard modules + webapp contract members + string
</task>
<task type="auto" tdd="true">
<name>task 2: Banner + list + guidance/confirm/trip-end card builders + shell.css</name>
<files>web/src/guidance/banner.ts, web/src/shell.css, web/test/guidance-surface.test.mjs</files>
<name>task 2: Banner + maneuver-list builders + gantry blink</name>
<files>web/src/guidance/banner.ts, web/test/guidance-surface.test.mjs</files>
<read_first>
- 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)
</read_first>
<behavior>
@@ -180,6 +179,36 @@ Output: glyphs + 5 builders + 2 guard modules + webapp contract members + string
- the banner root is a <button> (full-width, the list toggle) with aria-controls → the list panel's id
- buildManeuverList: card panel (max-width 320px, role="region", aria-label=ariaManeuverList) + transparent tap-out layer; rows in trip order with glyph 24×24 + street + right-aligned distance; NEXT row = orange glyph + 8×8 orange lamp; FUTURE = matte-black glyph; PAST = outline ink glyph + outline text; rows are real text, glyphs aria-hidden
- startGantryBlink(el)/stopGantryBlink(el): JS-timed two-state toggle ~500 ms (the boot-card precedent — an indicator duty cycle, never CSS animation)
- structural: zero inline copy in the built surface (RS9-analog scan); the svgHost innerHTML whitelist is the ONLY sanctioned site
</behavior>
<action>
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).
</action>
<verify>
<automated>npm test -w web</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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</done>
</task>
<task type="auto" tdd="true">
<name>task 3: Guidance/confirm/trip-end card builders + shell.css guidance block</name>
<files>web/src/guidance/banner.ts, web/src/shell.css, web/test/guidance-surface.test.mjs</files>
<read_first>
- 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
</read_first>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>npm test -w web</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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</done>
<done>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</done>
</task>
<task type="auto" tdd="true">
<name>task 3: Capability guards — haptics + close protection + webapp members + gate bumps</name>
<name>task 4: Capability guards — haptics + close protection + webapp members + gate bumps</name>
<files>web/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</files>
<read_first>
- web/src/telegram/webapp.ts lines 31–65 (the contract: optional members only — grow it, never break existing fakes)
@@ -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
@@ -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=<id> 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 } -->
<!-- From 06-01 (routing workspace — bot already depends on it): classifyTurn(prevDeg, nextDeg),
decodePolyline6(s), deriveCumulativeDistanceM(points), snapToGraph? (NOT needed — see the
recorded call below), haversineM. Subpath-with-extension imports only. -->
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. -->
<!-- RECORDED CALL (UI-SPEC deviation, honesty-law rationale): the turn-card's maneuver line is
composed STREET-LESS — "{dist} — {action}" — because the FROZEN envelope carries geometry
only, no step names (GE2 field freeze stays; adding a 7th snapshot field is a user
decision, not a plan's). Snapping turn vertices to the graph to guess street names was
REJECTED: a wrong street name in a card is a lie; a street-less card is honest. The
UI-SPEC's "{action} onto {street}" form remains the app-side banner's job, where the
route model DOES carry steps. Record this call in the phase fidelity artifact. -->
<!-- From 06-04 (bot/src/routing/worker-router.ts — EXPORTED there): loadGraph(env):
Promise<RoadGraph | null> — the module-scope promise memo (1 R2 GET per cold isolate,
shared by /api/route AND scheduled(); null on absent/corrupt graph, never throws).
The cron imports this memo — a SECOND R2 loader is forbidden (one authority, one
cold-start cost per isolate). -->
<!-- D-12 DELIVERY (street text on the card — revised per plan-checker; supersedes the
street-less recorded call): the card's maneuver line is STREET-BEARING by DEFAULT.
The FROZEN envelope carries geometry only (GE2 field freeze holds — no steps field,
no envelope extension: path (b) is user-gated and NOT implemented), so the cron
reconstructs the street itself: snap the carded turn's vertex to the memoized R2
graph (snapToGraph) → nameAt — the SAME one-authority argument the app-side banner
relies on. DEGRADATION (D-03's designed-degradation pattern): graph absent/corrupt
in the cron isolate, or a snap miss → street-less composition with the bare verbs —
honest absence, never a guessed name. CPU tradeoff (cron-side snap per due tick vs
the free-plan budget) is a NAMED REVIEW ITEM in 06-08's blocking checkpoint: the
user ratifies the default or the street-less final state (which would then be
recorded in UI-SPEC's recorded-calls fidelity list). -->
<!-- Caption (UI-SPEC Turn-Card section, HTML, ≤1024 chars — ~120 used):
GUIDE // T-{nn} ← instrument constant, code-composed
<b>{dist} — {cardActionBare[action]}</b> ← uppercase, street-less per the recorded call
<b>{dist} — {cardAction[action]} {STREET}</b> ← DEFAULT (D-12): cardAction* verbs carry
ONTO/KE; STREET = graph-reconstructed,
uppercase; nameAt's unnamed id →
cardStreetUnnamed ("UNNAMED ROAD")
… degraded form (graph absent / snap miss):
<b>{dist} — {cardActionBare[action]}</b> ← street-less honest absence (D-03 pattern)
ETA {HH:MM} · {km} KM LEFT ← cardEtaTemplate
Best-effort projection — updates while the app is minimized. ← cardHonesty
Inline keyboard: [{ text: cardOpenButton, web_app: { url: "<MINI_APP_URL>/?g=<id>" } }]
@@ -105,15 +128,17 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
at second ~0 of each minute). Stateless: zero cursor keys, zero KV writes. -->
<!-- Bot copy keys (bot/src/i18n/strings.ts, en + ms, UI-SPEC pinned):
cardAction* (8) are the DEFAULT street-bearing verbs (UI-SPEC verbatim):
cardActionDepart "DEPART"/"MULA" · cardActionStraight "CONTINUE ON"/"TERUS KE" ·
cardActionLeft "TURN LEFT ONTO"/"BELOK KIRI KE" · cardActionRight "TURN RIGHT ONTO"/"BELOK KANAN KE" ·
cardActionSlightLeft "KEEP LEFT ONTO"/"SEDAKIT KIRI KE" · cardActionSlightRight "KEEP RIGHT ONTO"/"SEDAKIT KANAN KE" ·
cardActionUturn "MAKE A U-TURN ONTO"/"BUAT PUSARAN KE" · cardActionArrive "ARRIVE AT"/"TIBA DI"
(kept verbatim for the copy gate + future street-bearing use)
cardActionBare* (8) are the graph-absent DEGRADATION verbs (street-less honest absence):
cardActionBareDepart "DEPART"/"MULA" · cardActionBareStraight "CONTINUE"/"TERUS" ·
cardActionBareLeft "TURN LEFT"/"BELOK KIRI" · cardActionBareRight "TURN RIGHT"/"BELOK KANAN" ·
cardActionBareSlightLeft "KEEP LEFT"/"SEDAKIT KIRI" · cardActionBareSlightRight "KEEP RIGHT"/"SEDAKIT KANAN" ·
cardActionBareUturn "MAKE A U-TURN"/"BUAT PUSARAN" · cardActionBareArrive "ARRIVE"/"TIBA"
cardStreetUnnamed "UNNAMED ROAD"/"JALAN TANPA NAMA" ← nameAt's unnamed id, instrument voice
cardEtaTemplate "ETA {time} · {km} KM LEFT"/"ETA {masa} · {km} KM LAGI"
cardHonesty "Best-effort projection — updates while the app is minimized."/"Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan."
cardOpenButton "OPEN GUIDANCE"/"BUKA PANDUAN" -->
@@ -134,9 +159,10 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
<read_first>
- 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)
</read_first>
<behavior>
- 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
</behavior>
<action>
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).
</action>
<verify>
<automated>npm test -w bot</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption are all Node-testable with zero infrastructure</done>
<done>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</done>
</task>
<task type="auto" tdd="true">
@@ -170,12 +199,15 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
<read_first>
- 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`)
</read_first>
<behavior>
- 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/<kind>.png`, caption matching the task-1 anatomy, parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=<envelope id>`
- 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/<kind>.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=<envelope id>`
- 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
</behavior>
<action>
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).
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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</done>
<done>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</done>
</task>
<task type="auto">
@@ -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. |
</threat_model>
<verification>
- `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)
</verification>
<success_criteria>
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.
</success_criteria>
<output>
@@ -122,7 +122,7 @@ Output: harness knob + 7 captures + gallery artifact + a recorded real-drive ver
Live turn-by-turn guidance end-to-end: Rams maneuver banner + collapsible list + guidance card with honest ETA, haptic cues, silent engine-A rerouting, minimize → chat turn-cards via the Worker cron, seamless resume, BackButton full state graph with the abandon confirmation, and trip-end deletion messaging. Seven capture legs + the DSGN-02 guidance gallery are committed for review beside stitch_minimalist_route_navigator/active_route_guidance/screen.png.
</what-built>
<action>
Blocking human checkpoint (automation-first: task 1's captures + all three workspace suites + the CPU gate are green BEFORE this runs). The user performs Part A (gallery fidelity review) and Part B (the one real drive with the 7-step checklist below). No OpenCode automatable step remains: physical GPS, real road geometry, and human legibility/haptic judgment are the declared device-gate protocol (D-16, phases 3–5 precedent). The executor records the per-step verdict verbatim in the SUMMARY; a FAIL holds the phase open (the Phase 3 UNVERIFIED precedent) rather than silently closing it.
Blocking human checkpoint (automation-first: task 1's captures + all three workspace suites + the CPU gate are green BEFORE this runs). The user performs Part A (gallery fidelity review), Part B (the one real drive with the 7-step checklist below), and Part C (the turn-card street-source ratification — a NAMED review item from 06-07: cards ship street-bearing by DEFAULT via cron-side graph reconstruction, snap + nameAt over the isolate-memoized R2 graph, which is a CPU-budget tradeoff in the cron isolate; the user either confirms the default or ratifies the street-less degradation as final, in which case the call is recorded in UI-SPEC's recorded-calls fidelity list). No OpenCode automatable step remains: physical GPS, real road geometry, and human legibility/haptic judgment are the declared device-gate protocol (D-16, phases 3–5 precedent). The executor records the per-step verdict verbatim in the SUMMARY; a FAIL holds the phase open (the Phase 3 UNVERIFIED precedent) rather than silently closing it.
</action>
<how-to-verify>
Part A — gallery (5 min): open the guidance gallery composite (path in 06-08's SUMMARY). Confirm fidelity: banner ink hierarchy (white distance, beige street, orange arrow), no red in guidance chrome, radius-0, token-pure. Approve or list deviations (tokens.css wins over mockup; the spec records calls).
@@ -130,13 +130,14 @@ Output: harness knob + 7 captures + gallery artifact + a recorded real-drive ver
1. Deploy fresh (`npm run deploy -w bot` with the graph uploaded — 06-04) and open the Mini App from the bot.
2. Pick a golden destination, start driving. VERIFY: banner shows distance + arrow + street; compact strip; guidance card ETA progresses without whiplash; haptic at ~300 m approach and at the maneuver.
3. Miss a turn DELIBERATELY. VERIFY: no dialog, no blame — the banner calmly shows the rerouting state, then the new route snaps in.
4. Minimize the app mid-drive. VERIFY: within ~3–4 minutes a turn-card arrives in chat (glyph + ETA + distance + OPEN GUIDANCE).
4. Minimize the app mid-drive. VERIFY: within ~3–4 minutes a turn-card arrives in chat (glyph + street + ETA + distance + OPEN GUIDANCE).
5. Re-open via the card's OPEN GUIDANCE. VERIFY: guidance resumes at the progressed position (resuming state, then live).
6. Tap END TRIP → confirm card → YES, ABANDON. VERIFY: "trip ended" messaging; chat cards stop.
7. (Optional, executor-assisted) KV spot-check: `npx wrangler kv key list --namespace-id=<chat_sessions id> --prefix "guidance:"` shows no stale keys.
Record PASS/FAIL per step + anything that felt wrong (legibility in sunlight, haptic feel, card lag).
Part C — turn-card street ratification (2 min): read the SUMMARY's card-CPU note (06-07: one snapToGraph per carded tick vs the free-plan CPU budget). DECIDE: keep graph-reconstructed street names as the shipped default (D-12 as written), or ratify street-less bare-verb cards as final (recorded in UI-SPEC's recorded-calls fidelity list; 06-07's degradation path becomes the only path).
</how-to-verify>
<resume-signal>Type "approved" (drive + gallery pass) or list the failing steps/deviations</resume-signal>
<resume-signal>Type "approved" (drive + gallery + street ratification pass) or list the failing steps/deviations</resume-signal>
</task>
</tasks>
@@ -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:<id>`, 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>
## 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) |
</phase_requirements>
## 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:<id>`) | 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:<id>` 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:<id> │
│ │ (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<Response> { /* existing + /api/route + /api/guidance */ },
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
// 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<Env>;
```
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<Graph> | null = null; // module scope = per-isolate memo
function loadGraph(env: Env): Promise<Graph> {
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:<id>`; KV cannot be queried by chat_id value.
- What's unclear: whether the turn-card's OPEN deep-link should carry `startapp=<guidance-id>` (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=<id>`/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 `<MINI_APP_URL>/?g=<id>` (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)
@@ -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 `<automated>` 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