--- phase: 06-turn-by-turn-guidance-live-driving plan: 04 type: execute wave: 2 depends_on: [06-01, 06-03] files_modified: - routing/scripts/build-road-graph.mjs - routing/scripts/upload-road-graph.mjs - routing/scripts/measure-route-cpu.mjs - routing/src/http-source.ts - routing/src/index.ts - routing/data/.gitignore - bot/src/routing/worker-router.ts - bot/src/webhook.ts - bot/src/config.ts - bot/wrangler.jsonc - bot/test/route-privacy.test.mjs - bot/test/webhook.test.mjs - docs/ROAD-GRAPH.md autonomous: true requirements: [GUID-02] user_setup: - service: cloudflare-r2 why: "D-01: the KL road graph hosts in R2 (first repo binding); forks upload their own" env_vars: [] dashboard_config: - task: "R2 bucket araharah-graphs must exist before upload + real routing" location: "`npx wrangler r2 bucket create araharah-graphs` (executor attempts it; on wrangler-auth failure this becomes the checkpoint — Cloudflare dashboard → R2 → Create bucket works too)" 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" - "/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" - "A* CPU on the real graph is measured and printed, p95 inside the 10 ms free-plan budget (A1)" artifacts: - path: "routing/scripts/build-road-graph.mjs" provides: "Overpass extract → CSR binary (maintainer tool, collect-then-write, stats gate)" - path: "routing/src/http-source.ts" provides: "createHttpRoutingSource — web's RoutingSource backend #2" exports: ["createHttpRoutingSource"] - path: "bot/src/routing/worker-router.ts" provides: "R2 graph load (isolate-memoized) + snap + A* + steps → RouteResult" - path: "bot/wrangler.jsonc" provides: "r2_buckets binding ROAD_GRAPH (still exactly ONE kv_namespaces — GE5)" - path: "docs/ROAD-GRAPH.md" provides: "fork setup: bucket, upload, licensing, envelope law" key_links: - from: "bot/src/webhook.ts" to: "bot/src/routing/worker-router.ts" via: "/api/route arm (Bearer-claim gated like /api/location)" pattern: "handleRoute|/api/route" - from: "bot/src/routing/worker-router.ts" to: "env.ROAD_GRAPH" via: "module-scope promise memo — 1 R2 GET per cold isolate" pattern: "ROAD_GRAPH\\.get" - from: "routing/src/http-source.ts" to: "RoutingSource" via: "implements the Phase 5 seam — network fail → null (fail-honest)" pattern: "RoutingSource" --- Engine A's server surface (D-01): the real KL graph build tool (Overpass → CSR binary), the R2 hosting + upload path with fork docs, the isolate-memoized Worker router behind a Bearer-gated `/api/route`, the client-side `createHttpRoutingSource` RoutingSource backend #2, RP1/RP2 re-pinned in the same change, and the A*-CPU measurement gate printed and enforced. Purpose: GUID-02's rerouting becomes REAL — arbitrary current positions route for real, $0, no VPS. This is the phase's top technical risk (A1: 10 ms free-plan CPU), so the measurement gate is a deliverable, not a hope. Output: build/upload/measure scripts + http-source + worker-router + `/api/route` + re-pinned gates + docs, real graph built and measured. @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md decodeGraph(buffer: ArrayBuffer): RoadGraph | null snapToGraph(graph: RoadGraph, point: RoutePoint): { node: number; distanceM: number } | null routeAstar(graph: RoadGraph, originNode: number, goalNode: number): { nodes: number[]; seconds: number; distanceM: number } | null deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[] // RoadGraph carries bbox + maxSpeedKmh + nameAt(id) **Isolate memoization is the cache that works** (RESEARCH): `caches.default` is useless here — Bearer requests auto-bypass Workers Cache and cron is never cached. Module-scope promise memo, 1 R2 GET per cold isolate. task 1: Road-graph build tool (Overpass → CSR binary) + real KL graph routing/scripts/build-road-graph.mjs, routing/data/.gitignore - routing/scripts/fetch-route-fixtures.mjs lines 1–64, 110–142, 185–248 (THE maintainer-tool pattern to copy: #!/usr/bin/env node, dense MAINTAINER-TIME header + Usage + Exit codes, named constants up top, fail() helper, pre-flight validation, collect-then-write, post-write stats + "next:" hint) - routing/test/fixtures/build-mini-graph.mjs + routing/src/graph.ts (the exact encodeGraph contract + pinned CSR layout this script must produce) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md A7 (Overpass response size/timeout) + Pitfall 1 (road-class filter) + Open Q 4 (bbox derivation) - search/src/gazetteer.ts getEntries (bbox extremes source) - `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) - 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) - `--check ` mode: decode with decodeGraph, print stats, exit 0/1 — no network - a second run with the committed file present and `--check` green needs no refetch (artifact, not input) Write `routing/scripts/build-road-graph.mjs` in the fetch-route-fixtures.mjs shell: header naming the D-01 boundary ("A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, web/src, or routing/src imports it"), named constants (`OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"`, `REQUEST_TIMEOUT_MS = 300_000`, `USER_AGENT`, `MAX_BINARY_BYTES = 80 * 1024 * 1024`, `DRIVABLE_CLASSES` + `CLASS_SPEED_KMH` tables, `BBOX_MARGIN_DEG = 0.05`), bbox COMPUTED at run start from `@araharah/search/src/gazetteer.ts` getEntries() + `routing/test/fixtures/golden-pairs.json` pair endpoints ± margin (printed; Open Q 4's mechanical pinning), the Overpass query `way[highway~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|living_street)$"](bbox); out geom;`, response → nodes deduplicated by rounded coord → edges with oneway/flags/speed/name → `encodeGraph` (import `../src/graph.ts`) → collect-then-write (committed file untouched on any failure, exit 1) → stats gate (size ≤ MAX_BINARY_BYTES else exit 1) → post-write summary + `next:` hint naming the upload command. `--check` mode decodes and re-prints stats. Also create `routing/data/.gitignore` containing `*.bin` (the binary is an ODbL-derived build artifact; committed artifacts are the script, docs, and the tiny test mini-graph). Then RUN it (network fetch, minutes) and confirm the stats gate passes; record the printed counts in the plan summary. node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin && node -e "const s=require('fs').statSync('routing/data/kl-roadgraph.v1.bin'); if(s.size>80*1024*1024) process.exit(1); console.log('graph bytes:', s.size)" - routing/scripts/build-road-graph.mjs exists with the maintainer-tool header, named constants (no inline magic), collect-then-write, and the 80 MB stats gate - routing/data/kl-roadgraph.v1.bin exists, > 1 MB, ≤ 80 MB, and passes `--check` (decodeGraph green) - routing/data/.gitignore contains `*.bin` and `git status --porcelain routing/data` shows no .bin staged - printed stats include nodeCount, edgeCount, binary size, bbox (executor records them in the SUMMARY) - grep `footway|cycleway|service|path` → absent from DRIVABLE_CLASSES (Pitfall 1) The real KL graph builds reproducibly from Overpass inside the D-01 envelope with the class filter and stats gate standing guard task 2: http-source (backend #2) + worker-router + Bearer-gated /api/route + RP re-pins routing/src/http-source.ts, routing/src/index.ts, bot/src/routing/worker-router.ts, bot/src/webhook.ts, bot/src/config.ts, bot/wrangler.jsonc, bot/test/route-privacy.test.mjs, bot/test/webhook.test.mjs - routing/src/fixture-source.ts lines 35–95, 134–189 (the backend-factory skeleton: slug guard first, lazy memoize, defensive validation, D-03 honest nulls — copy the posture) - bot/src/webhook.ts (the /api/location arm to mirror: Bearer claim → 401, body cap, category logs; route-dispatch skeleton) - bot/src/config.ts (the typed Env contract — one line per field with the trailing law comment) - bot/wrangler.jsonc (JSONC with law-bearing comments; GE5 asserts exactly ONE kv_namespaces entry) - bot/test/route-privacy.test.mjs lines 1–24 (RP1/RP2 as they stand — the doc-comments name Phase 6 as the re-pinning owner) - search/src/gazetteer.ts getEntryById(id) (destination resolution server-side) - bot/test/webhook.test.mjs (offline pattern; extend for the /api/route block with an R2 fake: `{ get: async () => ({ arrayBuffer: async () => miniGraphBuffer }) }`) - createHttpRoutingSource: POST {origin, destinationId} to endpoint with Bearer token from the injected getter; non-200, malformed body, or thrown fetch → null (fail-honest — T-05-04's .catch(()=>null) law, network edition); a valid RouteResult-shaped response passes defensive validation (destinationId match, options 1..3, finite numbers) → RouteResult - worker-router handleRoute: no/invalid Bearer → 401 [route] rejected: unauthorized; body > 4096 → 413; non-numeric/out-of-bbox origin lat/lng → 400 [route] rejected: malformed (V5 clamp-to-bbox validation); unknown destinationId → 200 null-ish? NO — unknown destination or snap miss or absent graph → Response JSON `null` with 200 (the null contract carries over HTTP: honest router-pending, never a 500) - happy path through the R2-faked mini-graph: real origin/dest resolve → 200 RouteResult with backend "engine-a", one option (NO alternatives — RESEARCH anti-pattern: alternatives never during the guidance path), steps carrying kind/name/distance/polyline6 - graph object absent (R2 fake returns null) → 200 null (D-03 designed degradation) - RP1 re-pinned: a /api/route handler WITHOUT the Bearer-claim derivation fails the gate; RP2 re-pinned: @araharah/routing subpath imports allowed in bot/src; any other new workspace dep fails - GE5 still green: exactly ONE kv_namespaces entry (r2_buckets is a different binding class) 1. `routing/src/http-source.ts`: `createHttpRoutingSource(opts: { endpoint: string; bearer: () => string | null; fetchImpl?: typeof fetch }): RoutingSource` — name "engine-a"; POST JSON {origin, destinationId}; Authorization only when bearer() returns non-null; response.json() → defensive shape check (copy fixture-source's isUsablePair style: every field type-checked, non-finite refused, try/catch → null); export from `routing/src/index.ts` alongside a header-note update (backend #2 live, ladder: fixture → engine-a → engine B documented post-v1). 2. `bot/src/routing/worker-router.ts`: module-scope `let graphP: Promise | null = null;` + `loadGraph(env)` — `env.ROAD_GRAPH.get(GRAPH_KEY)` with `GRAPH_KEY = "kl-roadgraph.v1.bin"`, `.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()))`, catch → null (a corrupt binary degrades honest); memo never reset (per-isolate). `handleRoute(req, env): Promise`: Bearer claim (extract + getClaimByToken — reuse webhook.ts's exact helper posture), body-cap 4096 (`MAX_ROUTE_BODY_CHARS`), origin {lat,lng} numeric + inside graph.bbox (400 otherwise), `getEntryById(destinationId)` (import `@araharah/search/src/gazetteer.ts`) → null miss → 200 null; snapToGraph origin + destination → null miss → 200 null; routeAstar → null → 200 null; else build RouteResult `{ destinationId, origin, options: [{ geometry: { points: nodePath points, cumulativeDistanceM: deriveCumulativeDistanceM(points) }, summary: { distanceM, durationS }, steps: deriveSteps(...) }], backend: "engine-a" }` → 200 JSON. 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). npm test -w bot && npm run routing:test && npm run bot:typecheck && npm run routing:typecheck - 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 - 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 - both typechecks clean The routing oracle is Bearer-gated and live in dev (R2 faked in tests), the client seam has its backend #2, honest-null degradation is proven at every miss class, and the negative-space gates were re-pinned by the change that invalidated them task 3: R2 bucket + upload script + fork docs + A*-CPU measurement gate routing/scripts/upload-road-graph.mjs, routing/scripts/measure-route-cpu.mjs, docs/ROAD-GRAPH.md - routing/scripts/build-road-graph.mjs (task 1 — the "next:" hint names this script; keep the maintainer-tool shell identical) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pitfall 1 (CPU measurement mandate), Assumption A1 (mitigation ladder), Environment Availability (R2 bucket "not yet created") - web/test/build-artifact.test.mjs BA9 (the "budget gates print their measurement" precedent this gate follows) - upload-road-graph.mjs: verifies routing/data/kl-roadgraph.v1.bin exists (> 1 MB), shells `npx wrangler r2 object put araharah-graphs/kl-roadgraph.v1.bin --file routing/data/kl-roadgraph.v1.bin --remote`, prints the key + next steps; missing file → exit 1 with the build hint - measure-route-cpu.mjs: loads the real binary, runs 25 A* routes between sampled node pairs spread across the bbox, prints per-route CPU ms + p95, exits 1 if p95 > 8 ms (20% margin under the 10 ms free-plan budget — A1); absent binary → exit 1 with the build hint - R2 bucket `araharah-graphs` exists on the account (wrangler CLI; on auth failure the executor surfaces a human checkpoint with the exact command — the bucket create is the one step a keyless machine cannot do) - docs/ROAD-GRAPH.md documents: build → check → upload → deploy sequence, the fork contract (your own bucket, your own upload), ODbL attribution for the OSM-derived graph, the 30–80 MB envelope law, and the p95 CPU gate 1. Attempt `npx wrangler r2 bucket create araharah-graphs`. If wrangler reports auth/permission failure, create a `checkpoint:human-action` note in the run log with the exact command + dashboard path (Cloudflare dashboard → R2 → Create bucket) and continue with dev-only verification — flag it in the SUMMARY as the phase's user_setup item. If it succeeds, proceed. 2. Write `routing/scripts/upload-road-graph.mjs` (fetch-route-fixtures.mjs shell) and RUN it (when the bucket exists) — verify with `npx wrangler r2 object get araharah-graphs/kl-roadgraph.v1.bin --file /tmp/opencode/graph-roundtrip.bin --remote` + `--check` round-trip (or `r2 object info`). 3. Write and RUN `routing/scripts/measure-route-cpu.mjs` (Node: `process.hrtime.bigint()` around routeAstar; pairs sampled from graph nodes across the bbox quadrants + the fixture-corpus endpoints snapped via snapToGraph). Record the printed p95 in the SUMMARY. If p95 > 8 ms, apply A1's mitigation ladder IN ORDER and re-measure: (a) drop tertiary/residential from DRIVABLE_CLASSES and rebuild (tighter graph), (b) bidirectional A* variant, (c) document Workers Paid as an explicit fork decision (breaks $0 — requires user sign-off; stop and surface it). 4. Write `docs/ROAD-GRAPH.md` per the behavior list (concise — one page). node routing/scripts/measure-route-cpu.mjs 2>&1 | tail -3 && node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin - measure-route-cpu.mjs prints "p95" + a millisecond number and exits 0 (p95 ≤ 8 ms), or the SUMMARY records which A1 mitigation was applied and the new p95 - upload-road-graph.mjs exists with the maintainer shell; if the bucket exists the round-trip get + --check proves the upload - docs/ROAD-GRAPH.md exists covering: bucket create, upload command, ODbL attribution, envelope law, CPU gate - the R2 bucket state (created / needs-user) is recorded in the SUMMARY Graph hosting is fork-ready ($0, scripted, documented), the binary is measured against the free-plan CPU ceiling with a printed verdict, and A1's risk is retired or explicitly escalated ## Trust Boundaries | Boundary | Description | |----------|-------------| | Internet → /api/route | Untrusted callers would love a free routing oracle; identity via Bearer claim only | | Request body → A* | Origin coordinates are untrusted input reaching a compute-heavy path | | R2 object → decodeGraph | Fork-uploaded binary (covered by 06-01's header validation — decodeGraph null on any mismatch) | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-06-01 | DoS / Elevation of Privilege | /api/route | mitigate | Bearer-gated exactly like /api/location (401 class tested); the 10 ms CPU cap is the natural per-request rate limiter; body cap 4096 (413). RP1 re-pinned to fail any unauthenticated/proxy-shaped routing arm. | | T-06-01b | Denial of Service (compute) | handleRoute | mitigate | V5: origin lat/lng type+range-validated against graph.bbox BEFORE snap/A* (400 class); unknown destinationId short-circuits before any graph work; alternatives generation prohibited on this path (single best route — RESEARCH anti-pattern). | | T-06-03 | Tampering | loadGraph → decodeGraph | mitigate | decodeGraph's magic+version+size check (06-01) → null → designed router-pending (D-03); upload script is maintainer-surface only; corrupt binary in R2 degrades honest, never crashes the Worker. | | T-06-04 | Information Disclosure | handleRoute logs | mitigate | Category-only logging (`[route] rejected: `) — never coordinates, ids, or payloads (the webhook law extended). | | — | Information Disclosure (fork) | docs/ROAD-GRAPH.md | accept | The graph is public OSM-derived data (ODbL-attributed) — hosting it is not a privacy surface; documented licensing keeps forks honest. | - `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 - CPU gate: p95 ≤ 8 ms printed (A1 retired) or escalation recorded - RP1/RP2 re-pinned green; GE5 (one KV namespace) still green Engine A routes real positions for real: build → upload → Bearer-gated `/api/route` → client `createHttpRoutingSource` — all $0, fork-friendly, honestly degrading when the graph is absent, and measured against the free-plan CPU ceiling. GUID-02's "recalculates from the current position" now has an engine behind it. After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md`