Files

424 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 6: Turn-by-Turn Guidance (Live Driving) - Pattern Map
**Mapped:** 2026-10-24
**Files analyzed:** 25 new/modified files across `routing/`, `web/src/guidance/`, `web/src/location|map|route|telegram/`, `bot/src/`, `bot/wrangler.jsonc`, and the three test workspaces
**Analogs found:** 22 / 25 (3 partial — the CSR/A\* data plane, the glyph rasterizer script, and the Worker `scheduled()` handler are repo firsts; RESEARCH.md Patterns 1–2 + Code Examples are the fallback)
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `routing/src/graph.ts` (new) | model (binary decode) | transform (ArrayBuffer → typed arrays) | `routing/src/polyline.ts` codec + `fixture-source.ts` lazy index | role-match |
| `routing/src/snap.ts` (new) | utility (geo math) | transform | `routing/src/geo.ts` | exact |
| `routing/src/astar.ts` (new) | service (path computation) | batch | none — pure-math posture of `routing/src/geo.ts`; layout per RESEARCH Patterns 1–2 | partial |
| `routing/src/http-source.ts` (new) | service (RoutingSource backend #2) | request-response | `routing/src/fixture-source.ts` | exact |
| `routing/scripts/build-road-graph.mjs` (new) | build tool (maintainer) | batch + file-I/O | `routing/scripts/fetch-route-fixtures.mjs` | exact |
| `routing/scripts/build-glyphs.mjs` (new) | build tool (maintainer, sharp) | file-I/O | `fetch-route-fixtures.mjs` shell (collect-then-write, exit codes) | role-match |
| `bot/src/routing/worker-router.ts` (new) | service (R2 graph load + route handler) | request-response | `fixture-source.ts` lazy-memoize posture (LAW 1) | role-match |
| `bot/src/guidance/cron.ts` (new) | service (scheduled projection + push) | event-driven (cron tick) | `webhook.ts` handler shape + `bot/src/bot/bot.ts` `createBot(env, {baseFetch})` | role-match (first `scheduled()` in repo) |
| `bot/src/kv/guidance-sessions.ts` (MODIFY: types → service) | model + service | CRUD (KV) | `bot/src/kv/location-pin.ts` + `bot/src/kv/sessions.ts` | exact |
| `bot/src/webhook.ts` (MODIFY: +`scheduled()`, +`/api/route`, +`/api/guidance`) | controller (Worker routes) | request-response + event | itself (its own fetch-handler pattern) | exact |
| `bot/src/config.ts` (MODIFY: +`ROAD_GRAPH: R2Bucket`) | config | — | itself (typed Env contract) | exact |
| `bot/wrangler.jsonc` (MODIFY: +`r2_buckets`, +`triggers.crons`) | config | — | itself | exact |
| `bot/package.json` (MODIFY: +`@araharah/routing` dep) | config | — | itself (`@araharah/search` workspace-dep precedent) | exact |
| `web/src/guidance/guidance.ts` (new) | service (pure state machine) | event-driven (fix stream reducer) | `web/src/route/route.ts` controller shape + `web/src/location/chain.ts` pure-injected-clock posture | role-match |
| `web/src/guidance/eta.ts` (new) | utility (EMA + deadband) | transform | `routing/src/geo.ts` (pure, named constants) | exact |
| `web/src/guidance/banner.ts` (+ list + guidance-card builders) (new) | component builders | request-response (DOM compose) | `web/src/route/route-card.ts` | exact |
| `web/src/guidance/watch.ts` (new) | hook (watchPosition + ≥10 m/≥5 s throttle) | streaming | `web/src/location/webview.ts` + `telegram.ts` Fix normalization | exact |
| `web/src/guidance/` capability guards (haptics, `disableVerticalSwipes`, closing confirmation) | middleware (guards) | request-response | `web/src/telegram/webapp.ts` optional members + `safe-area.ts` version gate | exact |
| `web/src/telegram/backbutton.ts` (MODIFY: 3-state graph) | middleware (BackButton wire) | request-response | itself (`wireBackButton` — predicate+handler-as-one-change) | exact |
| guidance camera locked-follow (new, in `web/src/guidance/` or `map/`) | component | request-response | `web/src/map/follow.ts` + `web/src/ui/recenter.ts` | exact |
| `web/src/route/route.ts` (MODIFY: evolves into guidance chrome) | controller | request-response | itself | exact |
| `routing/test/{graph,astar,snap}.test.mjs` + mini-graph fixture (new) | test | batch | `routing/test/fixture-source.test.mjs` + `contract.test.mjs` | exact |
| `web/test/guidance-surface.test.mjs` (new) | test (behavioral + structural gates) | — | `web/test/route-surface.test.mjs` | exact |
| `web/test/guidance-replay.test.mjs` + `web/test/fixtures/drives/*.json` (new) | test + fixture corpus | streaming replay | `web/test/location-chain.test.mjs` stubs + `routing/test/fixtures/golden-pairs.json` corpus | role-match |
| `web/test/guidance-eta.test.mjs` (new) | test | — | replay-fixture style of the two above | role-match |
| `bot/test/guidance-cron.test.mjs` (new) | test | — | `bot/test/webhook.test.mjs` (undici offline) + KV-fake style of `bot/test/sessions.test.mjs` | role-match |
| `bot/test/guidance-envelope.test.mjs` (MODIFY: GE4–GE7 re-pin), `bot/test/route-privacy.test.mjs` (MODIFY: RP1/RP2 re-pin), `bot/test/privacy.test.mjs` (MODIFY: PG1 module list) | test gates | — | themselves (each gate's doc-comment names Phase 6 as the re-pinning owner) | exact |
## Pattern Assignments
### `routing/src/http-source.ts` + `bot/src/routing/worker-router.ts` (service, request-response)
**Analog:** `routing/src/fixture-source.ts` — the backend-ladder construct every new RoutingSource implements.
**The seam being implemented** (`routing/src/types.ts` lines 89–100):
```typescript
// THE NULL CONTRACT IS LOAD-BEARING (D-03): route() resolving to NULL is
// the honest "router pending" state — an unknown destination, or an origin
// outside the fixture corpus's tolerance. Never a blank map, never a fake
// route, never a thrown error for a normal miss.
export interface RoutingSource {
readonly name: string;
route(req: RouteRequest): Promise<RouteResult | null>;
}
```
**Backend factory shape — copy this skeleton** (`fixture-source.ts` lines 156–189):
```typescript
export function createFixtureRoutingSource(data?: RouteFixturesFile): RoutingSource {
let index: PairIndex | null = null;
const load = (): PairIndex => {
if (index === null) {
index = data === undefined ? loadDefaultIndex() : buildPairIndex(data);
}
return index;
};
return {
name: "fixture",
async route(req: RouteRequest): Promise<RouteResult | null> {
// Slug guard FIRST — a hostile shape never reaches the index (T-05-03).
if (!ID_PATTERN.test(req.destinationId)) return null;
const idx = load();
const candidates = idx.byDestination.get(req.destinationId);
if (candidates === undefined) return null; // D-03: honest pending
// ... match → RouteResult; else return null
return null;
},
};
}
```
**Lazy memoize at module scope (LAW 1 — pay nothing at startup)** (`fixture-source.ts` lines 134–141, mirrored in the doc header lines 7–10):
```typescript
let defaultIndex: PairIndex | null = null;
function loadDefaultIndex(): PairIndex {
if (defaultIndex === null) {
defaultIndex = buildPairIndex(fixturesData as RouteFixturesFile);
}
return defaultIndex;
}
```
`worker-router.ts` transposes this to the R2 graph — RESEARCH.md "R2 graph load + isolate memoization" gives the exact promise-memo form:
```typescript
let graphP: Promise<Graph> | null = null; // module scope = per-isolate memo
function loadGraph(env: Env): Promise<Graph> {
graphP ??= env.ROAD_GRAPH.get(GRAPH_KEY)
.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()));
return graphP;
}
```
Absence of the graph object → `createHttpRoutingSource` resolves `null` (D-03's designed degradation), never throws.
**Defensive validation posture** (`fixture-source.ts` lines 66–95, `isUsablePair`): every field type-checked, non-finite numbers refused, a decode that throws marks the entry unusable (`catch { return false; }` lines 88–92) — the malformed entry renders as an honest null downstream, never a throw in the render path. `http-source.ts` applies the same shape to the Worker's JSON response; `graph.ts` applies it to the binary header (magic + version + size check, fail-honest per RESEARCH §Security).
**Imports convention** (`fixture-source.ts` lines 35–46): static import for data (`import fixturesData from "./data/route-fixtures.json" with { type: "json" }`), relative imports WITH `.ts` extension, types via `import type`. The package has no `exports`/`main` — consumers import the explicit subpath: `import { … } from "@araharah/routing/src/index.ts"` (`routing/src/index.ts` lines 5–11). New exports (`decodeGraph`, `snapToGraph`, `routeAstar`, `createHttpRoutingSource`) are added to `index.ts` as named exports only — no default export.
### `routing/src/snap.ts`, `routing/src/astar.ts`, `routing/src/graph.ts` (pure modules)
**Analog:** `routing/src/geo.ts` — the pure-module law.
**Header contract** (`geo.ts` lines 1–17): spherical math with named constants (`EARTH_RADIUS_M = 6_371_000`), "Pure: no I/O, no logging, no storage (the D-15 analog — nothing about a request outlives the caller's frame)". `haversineM` (lines 25–34) is the admissibility heuristic's distance oracle; `deriveCumulativeDistanceM` (lines 38–49) is the progress backbone the client snaps against:
```typescript
export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[] {
if (points.length === 0) return [];
const cumulative: number[] = [0];
for (let i = 1; i < points.length; i++) {
// cumulative.push(prevMeters + haversineM(prev, curr));
}
return cumulative;
}
```
Equirectangular point-to-segment math for `snap.ts` must scale lng by `cos(lat)` (RESEARCH Pitfall 8) and route all displayed distances through `cumulativeDistanceM`/`haversineM` — never raw Δdegrees.
**No analog exists for CSR layout / binary heap A\*** — RESEARCH.md Pattern 1 (CSR binary layout: header/nodes/offsets/edges/weights, zero-copy `DataView` views) and Pattern 2 (typed-array A\*, binary heap of indices, haversine÷maxSpeed heuristic, bidirectional variant) are the authoritative references. Graph **decode** (`graph.ts`) follows the codec posture of `routing/src/polyline.ts` (pure functions, decode/encode pair, header contract at `polyline.ts`'s doc block; round-trip proven by tests).
### `routing/scripts/build-road-graph.mjs` + `build-glyphs.mjs` (maintainer build tools)
**Analog:** `routing/scripts/fetch-route-fixtures.mjs` — the exact maintainer-tool pattern (D-12 boundary).
**Header/usage contract** (lines 1–47): `#!/usr/bin/env node`, a dense header stating "A MAINTAINER-TIME REPO TOOL … NOT a runtime dependency. Nothing in bot/src/, web/src/, or routing/src/ imports it", plus Usage / Exit codes sections.
**Named constants up top** (lines 57–64): `USER_AGENT`, `INTER_REQUEST_DELAY_MS = 1200`, `REQUEST_TIMEOUT_MS = 30_000`, tolerance values — never inline magic numbers.
**Pre-flight validation before any request leaves the machine** (lines 110–142): validate every input against committed data, `fail()` with the offending id named on the first violation.
```typescript
function fail(message) {
console.error(`fetch-route-fixtures: ${message}`);
process.exit(1);
}
```
**Collect-then-write** (lines 185–242): results accumulate in memory; the output file is written ONLY after every unit succeeds; a hard failure exits 1 with the committed file UNTOUCHED. `build-road-graph.mjs` copies this verbatim: Overpass query → validate → CSR encode → stats print (node/edge counts vs the D-01 30–80 MB envelope — a stats gate, like line 244–248's post-write summary + "next:" hint) → write `kl-roadgraph.v1.bin`. `build-glyphs.mjs` keeps the same shell around sharp's SVG→PNG (sharp is a NEW devDependency: `npm install -D -w routing sharp` — zero runtime deps added, per RESEARCH §Installation).
### `web/src/guidance/guidance.ts` (service — pure state machine, event-driven)
**Analog:** `web/src/route/route.ts` for the state-machine/controller shape; `web/src/location/chain.ts` for the pure-injected-clock posture.
**Injected-deps state machine with a total walk-back** (`route.ts` lines 55–82 + 174–187): every DOM/map concern is injected so tests run in Node ("All DOM/map concerns are INJECTED so the RS gates run in Node … the module itself touches no DOM and no maplibre value import"). The state transitions funnel through one `setState` that fires `onStateChange` (lines 88–91) — main.ts derives the BackButton predicate from it. `stepGuidance(state, fix, routeModel, nowMs) → {state, effects[]}` (RESEARCH Pattern 3) is the same idea, purer: no DOM concerns at all, injected clock.
**Fail-honest error posture** (`route.ts` lines 95–104):
```typescript
const result: RouteResult | null = await deps.source
.route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id })
.catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line
```
REROUTING failures resolve to a calm on-route-holds / pending posture, never a thrown error in the fix loop.
**Console discipline** (`route.ts` lines 30–31 + RS8 gate at `route-surface.test.mjs` lines 498–513): the ONLY console lines are state tokens (`ARAHARAH-ROUTE state=drawn`), never template interpolation, never coordinates. Guidance adds its own `ARAHARAH-GUIDANCE state=…` family under the same law (D-14 category-only extended client-side).
**Named constants exported at top** (`route.ts` lines 46–52): `ROUTE_CAMERA_PADDING`, `ROUTE_FIT_DURATION_MS`, `ROUTE_CAMERA_PITCH` — guidance's `OFF_ROUTE_THRESHOLD_M`, `OFF_ROUTE_CONSECUTIVE_FIXES`, `OFF_ROUTE_GRACE_MS`, `STORM_GUARD_MS`, `MISSED_TURN_PAST_MANEUVER_M`, `ARRIVAL_THRESHOLD_M` (RESEARCH Pattern 3 table) follow this export-and-pin convention; a test asserts the exported values (RS4 camera constants, `route-surface.test.mjs` lines 563–568).
**Fix shape consumed** (`web/src/location/types.ts` lines 20–33) — replay fixtures are Fix-shaped JSON (same six fields):
```typescript
export type Fix = {
lat: number; lng: number;
accuracyM: number | null;
headingDeg: number | null;
ts: number;
source: FixSource;
};
```
### `web/src/guidance/watch.ts` (hook, streaming)
**Analog:** `web/src/location/webview.ts` + `web/src/location/telegram.ts`.
**The wrapper pattern** (`webview.ts` lines 18–44): promise-wrapped callback, `settled` latch, hard timeout (a wedged client can never wedge the caller), errors resolve `null` — "denial / unavailability = null; the chain's normal signal (no error object escapes)". Options: `{ enableHighAccuracy: true, timeout, maximumAge: 0 }`. The guidance watch extends this to `watchPosition` (same `maximumAge: 0`, high-accuracy) with the D-15 distance throttle applied app-side:
```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 OR ≥5 s
```
**Fix normalization precedent** (`telegram.ts` lines 48–56): map the platform payload into the six-field Fix with `source: "…"`, `accuracyM ?? null`, `headingDeg ?? null`. `chain.ts` lines 10–15 record the cadence contract this phase finally flips: "a Fix-shaped abstraction with a PLUGGABLE cadence and hard-codes NO continuous-mode interval … Phase 6 chooses between bounded getLocation() polling and watchPosition() without a rewrite."
### `web/src/guidance/banner.ts` + list + guidance-card (component builders)
**Analog:** `web/src/route/route-card.ts` — the builder law.
**createElement + textContent ONLY** (`route-card.ts` lines 11–15 header, enforced by RS5 at `route-surface.test.mjs` lines 424–434): "a name-derived string never reaches innerHTML". Structure: actions interface first (lines 26–31), then a pure builder returning an `HTMLElement`:
```typescript
export interface RouteCardActions {
onSelect(index: number): void;
onSearchAgain(): void;
}
export function buildRouteCard(locale, destinationName, options, selectedIndex, actions): HTMLElement {
const loc = strings[locale];
const card = document.createElement("div");
card.className = "card";
// ... header / headline / telemetry / diff appended via createElement+textContent
return card;
}
```
**Theme classes only, no local restyle** (`cards.ts` lines 2–8): `.card, .card-header, .card-ref, .btn-primary, .btn-secondary, .text-headline-md, .text-body-md, .text-body-sm` from the theme/shell — never restyled locally. Telemetry numbers use `.text-telemetry-num` (line 69), the first-consumer precedent for the banner's distance/ETA digits.
**Copy lives ONLY in `web/src/i18n/strings.ts`** (`strings.ts` lines 17–63 interface + COPY5 gate; RS9 at `route-surface.test.mjs` lines 517–542 greps the route family for inline English/Malay). New banner/confirm/trip-end copy keys are added to `ShellStrings` in both `en` and `ms`; clinical-honest tone. **Instrument formats are composed in code, not locale strings** — `fillTemplate` (lines 193–195) + `chip.textContent = \`${String(index + 1).padStart(2, "0")} // ${minutes} MIN · ${km} KM\`` (line 172). The banner's "480 m → JALAN AMPANG" format and the turn-card caption follow this.
**aria attributes** on interactive groups (`route-card.ts` lines 88–89): `role="group"` + `aria-label` from the strings module.
### Guidance camera (locked follow + recenter return)
**Analog:** `web/src/map/follow.ts` + `web/src/ui/recenter.ts`.
**Mode machine with a programmatic-move lock** (`follow.ts` lines 14–43):
```typescript
export const CAMERA_PADDING_PX = 96; // 12 x space-sm
export const RECENTER_DURATION_MS = 400;
export type FollowMode = "following" | "free";
export function createFollowController(map, onModeChange) {
map.on("movestart", (e) => {
if (lock) return;
if (e.originalEvent) set("free"); // a user gesture only, never a programmatic move
});
return { follow(fix) { lock = true; map.flyTo({...}); set("following"); lock = false; } };
}
```
Locked-follow (D-10: center ≈65% down-screen via MapLibre padding, pitch 35°) extends `follow(fix)` to ease along the snapped line; user pan still unlocks via `originalEvent` (T-03-16) → the Phase 3 recenter control returns. **Control state is data-attributes + CSS** (`recenter.ts` lines 27–47): `button.dataset.follow = mode; button.dataset.fix = state;` — instant swaps, zero easing; the click handler is `addEventListener`, never inline onclick. `route.ts` lines 8–14 document the camera law the guidance camera must keep: pitch/bearing passed EXPLICITLY; fit stays center+zoom only.
### Capability guards (haptics, vertical swipes, closing confirmation, BackButton)
**Analog:** `web/src/telegram/webapp.ts` + `web/src/telegram/safe-area.ts` + `web/src/telegram/backbutton.ts`.
**The contract grows by OPTIONAL members only** (`webapp.ts` lines 31–65 — the hand-rolled `TelegramWebAppLike` IS the contract; `@types/telegram-web-app` stays deliberately out). RESEARCH.md "Telegram capability guards" names the exact additions:
```typescript
HapticFeedback?: { impactOccurred(style): unknown; notificationOccurred(type): unknown; selectionChanged(): unknown };
disableVerticalSwipes?: () => void; // Bot API 7.7+
enableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void; // Bot API 6.2+
isActive?: boolean; // 8.0+
// "activated" | "deactivated" via the existing onEvent?(event, cb)
```
**Version-gate BEFORE touching the API** (`safe-area.ts` lines 35–43, the established shape; `telegram.ts` lines 23–26 shows the null-safe variant):
```typescript
const tg =
wa && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0")
? wa.safeAreaInset
: null;
if (!tg) return base; // old-client fallback
```
Haptics are graceful no-ops where unsupported (D-08) — `?.` chains on the optional member.
**BackButton graph extension** (`backbutton.ts` lines 1–27): "its predicate and its handler ship as ONE change so Phase 6 extends the state graph rather than reinventing the wire". The 3-state walk (search → routes → guidance → search) makes `walkBack` context-aware; guidance-exit routes through the D-09 confirm card; never `close()` from a pushed state (RS7 proves the wire with a recording fake, `route-surface.test.mjs` lines 449–494).
**Safe areas during guidance:** `resolveSafeInsets` + `applySafeArea` (`safe-area.ts` lines 35–58) already write `--safe-top/bottom/left/right` custom properties on `:root` — the banner's top register padding consumes the existing tokens.
### `bot/src/kv/guidance-sessions.ts` (MODIFY: frozen types → TTL-only service)
**Analog:** `bot/src/kv/location-pin.ts` (the exact write/read/delete trio) + `bot/src/kv/sessions.ts` (multi-key rotation).
**The frozen contract being implemented** (`guidance-sessions.ts` lines 15–17, 49–58):
```typescript
export const GUIDANCE_SESSION_TTL_SECONDS = 7200;
export interface GuidanceSessionEnvelope {
id: string;
chat_id: number;
route_snapshot: GuidanceRouteSnapshot;
created_at: number;
}
```
Key scheme `guidance:<id>`; EXACTLY four fields — "a 5th property means drift (the SessionClaim law)".
**TTL-on-the-same-line write** (`location-pin.ts` lines 42–46):
```typescript
export async function writePin(kv: LocationPinKv, chatId: number, pin: LocationPin): Promise<void> {
await kv.put(`loc:${chatId}`, JSON.stringify(pin), { expirationTtl: PIN_TTL_SECONDS });
}
```
PG1 (`privacy.test.mjs` lines 32–49) mechanically enforces: every `.put(` line carries `expirationTtl`, referencing an ALL-CAPS named constant — never an inline literal. The new service's writes land in `privacy.test.mjs`'s `storageModules` list.
**Injected KvLike seam** (`sessions.ts` lines 53–57): `interface KvLike { get/put({expirationTtl})/delete }` — the service takes `kv: GuidanceSessionKv extends KvLike`, so tests inject a Map-backed fake (the `bot/test/sessions.test.mjs` pattern). `crypto.randomUUID()` ids (line 74, T-02-05).
**Defensive read with exact-field + range validation** (`location-pin.ts` lines 48–73): parse in try/catch, check every field's presence and type, range-check numerics, return `null` on any mismatch — "a corrupt or tampered record can never fly the client camera to null island." `getGuidanceSession` mirrors this against the frozen envelope shape (the cron must never push a card from a malformed record).
**Explicit delete for trip end (D-14):** `location-pin.ts` lines 75–79 `clearPin` is the model — one `await kv.delete(\`loc:${chatId}\`)`; guidance adds `deleteGuidanceSession(kv, id)` called on BOTH arrival and abandon paths.
### `bot/src/webhook.ts` (MODIFY: +`scheduled()`, +`/api/route`, +`/api/guidance`)
**Analog:** its own established handler pattern.
**Route-dispatch skeleton** (lines 54–84): pathname match → method check (405) → handler; `satisfies ExportedHandler<Env>` (line 84). RESEARCH.md's "Cron config + scheduled handler" example extends exactly this object:
```typescript
export default {
async fetch(req, env, ctx): Promise<Response> { /* existing + /api/route + /api/guidance */ },
async scheduled(controller: ScheduledController, env: Env, ctx): Promise<void> {
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>;
```
**Bearer-claim derivation — NEVER client-supplied chat ids** (`webhook.ts` lines 143–161, `handleLocation` — the T-03-01 precedent both new endpoints must copy):
```typescript
const header = req.headers.get("authorization") ?? "";
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
const token = match !== null && match[1] ? match[1] : null;
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
if (claim === null) {
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, ... });
}
```
`/api/guidance` mints the envelope with `chat_id: claim.chat_id`; `/api/route` is gated identically. Body-size caps follow `MAX_INITDATA_CHARS` + the pre/post-read double check (lines 91–119, WR-01) for request-body validation; lat/lng validation is hand-rolled (no zod — RESEARCH §V5).
**Category-only logging** (lines 37–43 law + call sites): `console.log("[initdata] rejected: malformed")` — never payload, never ids, never timestamps. Cron and routing paths extend this (D-14): `[guidance-cron] …` categories only.
**Fail-closed secret posture** (lines 58–71): reject with a constant response BEFORE any framework/library path runs when a required binding is missing — the shape to mirror if the R2 binding or graph key is absent (honest degradation, D-03).
### `bot/src/bot/bot.ts` (MODIFY-adjacent: cron-side sends)
**Analog:** `createBot(env, opts)` (lines 41–44) — the env-bound factory with an injectable `baseFetch` for offline tests:
```typescript
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
const bot = opts.baseFetch
? new Bot(env.BOT_TOKEN, { client: { fetch: opts.baseFetch } })
: new Bot(env.BOT_TOKEN);
```
`cron.ts` calls `createBot(env)` (works identically inside `scheduled()`); `guidance-cron.test.mjs` injects `baseFetch` to record `sendPhoto` calls without network — the turn-card send is `bot.api.sendPhoto(chat_id, url, { caption, parse_mode: "HTML" })` (RESEARCH "Turn-card send").
### `bot/src/config.ts` + `bot/wrangler.jsonc` (config MODIFY)
**Analog:** themselves — the typed-Env / config-comment style.
`config.ts` (lines 4–17): each field one line, trailing `//` comment naming its law (e.g. `chat_sessions: KVNamespace; // TTL-only namespace`). Add `ROAD_GRAPH: R2Bucket; // D-01 graph binary — R2 binding, first in the repo` plus any cron constants as named exports, NOT env reads. `wrangler.jsonc` (lines 1–24): JSONC with law-bearing comments; add:
```jsonc
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
"triggers": { "crons": ["*/1 * * * *"] }
```
(60 s tick; app-side logic decides whether a card is due — D-11's 60–120 s discretion.) KV namespace stays EXACTLY ONE (`chat_sessions`, line 23) — GE5 re-pins to "exactly one" still; the R2 bucket is NOT a KV namespace.
### `bot/package.json` (MODIFY)
**Analog:** itself — `bot/package.json` already carries the workspace-dep precedent: `"dependencies": { "grammy": "^1.46.0", "@araharah/search": "*" }`. Add `"@araharah/routing": "*"` (the research-named first routing import). Import form inside bot/src follows the subpath rule: `import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"`.
## Shared Patterns
### TTL-only KV (every write carries `expirationTtl`)
**Source:** `bot/src/kv/location-pin.ts` lines 42–46, `bot/src/kv/sessions.ts` lines 73–90; enforced by `bot/test/privacy.test.mjs` PG1 (lines 32–49)
**Apply to:** `bot/src/kv/guidance-sessions.ts` service functions; extend PG1's `storageModules` list in the same change.
```typescript
await kv.put(`guidance:${id}`, JSON.stringify(envelope), { expirationTtl: GUIDANCE_SESSION_TTL_SECONDS });
```
### Bearer-claim → server-derived key (V4 access control)
**Source:** `bot/src/webhook.ts` lines 143–161 (`handleLocation`)
**Apply to:** `/api/route`, `POST /api/guidance`, `DELETE /api/guidance`; the cron reads `envelope.chat_id` from KV only — never a client-supplied chat id anywhere.
### Category-only logging
**Source:** `bot/src/webhook.ts` lines 37–43, 112, 150, 196; client-side twin: `route.ts` state tokens + RS8 gate
**Apply to:** all cron + routing paths (bot) and all `web/src/guidance/` modules (state tokens only, no `${…}` interpolation — coordinates and chat ids never appear in any log line).
### Lazy memoize at module scope (LAW 1)
**Source:** `routing/src/fixture-source.ts` lines 134–141, 57–63
**Apply to:** `worker-router.ts` graph load (1 R2 GET per cold isolate), `graph.ts` decode cache, and any snap-grid index (built once per isolate, never per request).
### Fail-honest nulls (never fabricate, never throw for a normal miss)
**Source:** `routing/src/types.ts` lines 13–16 + `fixture-source.ts` lines 175–186; client twin `route.ts` lines 97–104
**Apply to:** `http-source.ts` (network/absent-graph → null → router-pending), `graph.ts` header check, `getGuidanceSession` (malformed → null), reroute failure inside the state machine.
### Pure module + injected deps (Node-testable without a browser)
**Source:** `web/src/location/chain.ts` lines 27–36 (injected `nowMs`, injected source functions); `route.ts` lines 33–36 (injected DOM/map); `geo.ts` (no I/O at all)
**Apply to:** `guidance.ts` (injected clock + RoutingSource), `eta.ts`, `cron.ts`'s `projectTurnCards()` (pure projection, injected now), `graph/astar/snap.ts`. Module header comments state the law (the `chain.ts` lines 1–20 style).
### Capability-guarded Telegram WebApp APIs
**Source:** `web/src/telegram/webapp.ts` lines 31–65 (optional members only) + `safe-area.ts` lines 35–50 (`isVersionAtLeast` gate) + `telegram.ts` lines 23–26 (null-safe support probe)
**Apply to:** haptics, `disableVerticalSwipes`, `enableClosingConfirmation`, `isActive`/`activated`/`deactivated` events, envelope POST-on-minimize.
### createElement + textContent builders; strings-module copy; theme classes only
**Source:** `web/src/route/route-card.ts` lines 11–15, 37–103, 193–195; `web/src/i18n/strings.ts` lines 17–63; gated by RS5/RS9 + COPY5
**Apply to:** banner, maneuver list, guidance card, "abandon route?" confirm card, "trip ended, data deleted" messaging — all copy keys land in `ShellStrings` (en + ms), all instrument formats composed in code via `fillTemplate`.
### Version-gated constants tables (camera + thresholds exported and pinned)
**Source:** `route.ts` lines 46–52 (`ROUTE_CAMERA_PADDING/…`), pinned by `route-surface.test.mjs` lines 563–568
**Apply to:** the D-04/D-07 constants (`OFF_ROUTE_THRESHOLD_M` … `ARRIVAL_THRESHOLD_M`, EMA α, deadband) — named exports, asserted by the replay/eta suites, replay-tuned without renaming.
### Hand-rolled test harness family
**Source:** `routing/test/harness.mjs` (10 lines: `test()` + registry), `routing/test/runner.mjs` (auto-discovers `*.test.mjs`, sorted; load-failure becomes one failing case; PASS/FAIL + exit code). `bot/test/` and `web/test/` are the same shape verbatim (confirmed by diff).
**Apply to:** every new suite registers via `import { test } from "./harness.mjs"`; new suites are dropped into the existing `test/` dirs — runner discovery picks them up with zero config.
### Behavioral fakes + structural gates (the two test styles)
**Source (behavioral):** `web/test/route-surface.test.mjs` — `fakeMap()` recording fake (lines 77–104), `fakeDeps()` full dep set capturing actions (lines 266–306), `withConsole()` capture helper (lines 309–318), Node-24 type-stripping dynamic imports of `.ts` sources (lines 37–46).
**Source (behavioral, DOM-free):** `web/test/location-chain.test.mjs` — `stubNavigator`/`stubFetch` install+restore-in-finally (lines 30–62), `fakeWebApp` with real `isVersionAtLeast` comparison (lines 68–89).
**Source (structural):** comment-stripped source introspection — `stripComments` + banned-token scans (`route-surface.test.mjs` lines 50–58; `guidance-envelope.test.mjs` lines 54–70; `route-privacy.test.mjs` lines 42–50).
**Apply to:** `guidance-surface.test.mjs` (copy of the RS style), `guidance-replay.test.mjs` (Fix-shaped JSON streams through `stepGuidance` — the chain-test stub style, no DOM at all), `guidance-cron.test.mjs` (undici Request/Response offline pattern of `bot/test/webhook.test.mjs`; KV Map-fake; `baseFetch`-captured `sendPhoto`).
### Gate re-pins ship WITH the change that invalidates them
**Source (the handoff notes):** `bot/test/guidance-envelope.test.mjs` lines 36–40 ("when the turn-card service legitimately adds the kv namespace binding, the Env field, and the consumer, updating GE4–GE7 is THAT plan's responsibility"); `bot/test/route-privacy.test.mjs` lines 1–24 (RP1 bans `/api/route` tokens, RP2 bans `@araharah/routing` in bot/src).
**Apply to:** the plans that add the envelope service re-pin GE4 (types-only → service), GE6 (Env gains `ROAD_GRAPH`), GE7 (bot/src may import guidance-sessions), and keep GE1 (TTL 7200), GE2 (field freeze), GE3 (inline snapshot) UNCHANGED — the frozen shape stays frozen. The plan adding `/api/route` + the routing import re-pins RP1 (ban *unauthenticated/proxy-shaped* routing) and RP2 (routing import now allowed, listed explicitly). GE5 stays: exactly ONE KV namespace (R2 is a separate binding class).
## No Analog Found
| File | Role | Data Flow | Reason | Fallback |
|------|------|-----------|--------|----------|
| `routing/src/astar.ts` + CSR layout in `graph.ts` | service | batch | No graph-search code exists anywhere in the repo | RESEARCH.md Pattern 1 (CSR binary layout) + Pattern 2 (typed-array A\*, binary heap, bidirectional); correctness gate = A\* equals brute-force Dijkstra on a committed mini-graph (`routing/test/` fixture, Wave 0) |
| `routing/scripts/build-glyphs.mjs` (sharp SVG→PNG) | build tool | file-I/O | No rasterization code exists; sharp is a new devDependency | Keep `fetch-route-fixtures.mjs`'s maintainer-tool shell (header/usage/exit codes/collect-then-write); rasterizer choice per RESEARCH Standard Stack (sharp preferred, `@resvg/resvg-js` fallback) |
| `bot/src/webhook.ts` `scheduled()` + `bot/src/guidance/cron.ts` | service | event-driven | First Cron Trigger in the repo — no scheduled-handler precedent | RESEARCH.md "Cron config + scheduled handler" code example (KV `list({prefix, cursor})` + `list_complete` pagination); local test via `curl "http://localhost:8787/cdn-cgi/local/scheduled"`; send shape from `bot.ts` `createBot(env)` |
| `web/test/fixtures/drives/*.json` corpus | fixture corpus | — | No position-stream fixtures exist (phase 3/4 used capture profiles, not recorded drives) | Shape = the six-field `Fix` (`web/src/location/types.ts` lines 22–33) as JSON streams; corpus-commit conventions from `routing/test/fixtures/golden-pairs.json` (committed data + header/attribution fields where licensing applies) |
## Metadata
**Analog search scope:** `routing/{src,test,scripts}/`, `web/src/{route,map,location,telegram,ui,i18n}/`, `web/test/`, `bot/src/{kv,bot}/`, `bot/test/`, `bot/wrangler.jsonc`, workspace `package.json`s
**Files read in full:** 24 source/test/config files (~2,700 lines); runners trio diff-confirmed identical in shape
**Pattern extraction date:** 2026-10-24
**Cross-cutting cautions for the planner (each pinned by an existing gate):**
1. **GE5 stays green:** the R2 bucket is a `r2_buckets` binding, NOT a second KV namespace — `guidance-envelope.test.mjs` GE5 still asserts exactly one `kv_namespaces` entry.
2. **PG1 scope grows:** `privacy.test.mjs` `storageModules` (line 25) must list `kv/guidance-sessions.ts` the moment it gains a write path.
3. **RS6 inversion:** `route-surface.test.mjs` RS6 (lines 438–445) currently BANS the tokens `progress`/`maneuver` in `web/src/route/` — the plans that move guidance chrome into `web/src/route/` must re-pin RS6 in the same change (the phase-5 header comment reserved this: `route.ts` D-10 "no start-guidance button … Phase 6" note).
4. **RS10 floor bump:** the no-storage scan floor (≥16 files, lines 546–559) grows with `web/src/guidance/` — bump it in the plan that creates the family.
5. **Workspace-import rule:** subpath-with-extension only (`@araharah/routing/src/index.ts`); a bare `@araharah/routing` specifier resolves to nothing (`routing/src/index.ts` lines 5–11) — and bot/package.json needs the dep added before bot/src imports it.