Files

35 KiB
Raw Permalink Blame History

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):

// 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):

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):

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:

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:

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.

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):

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):

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:

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:

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):

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:

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):

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):

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):

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:

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):

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:

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:

"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.

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.jsons 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.