Files
arah2/.planning/phases/05-routing-route-display/05-01-PLAN.md

39 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-routing-route-display 01 execute 1
package.json
routing/package.json
routing/tsconfig.json
routing/src/types.ts
routing/src/polyline.ts
routing/src/geo.ts
routing/src/fixture-source.ts
routing/src/index.ts
routing/src/data/route-fixtures.json
routing/test/runner.mjs
routing/test/harness.mjs
routing/test/contract.test.mjs
routing/test/polyline.test.mjs
routing/test/fixture-source.test.mjs
routing/test/loader-env.test.mjs
true
RTNG-01
decisions_implemented truths artifacts key_links
D-01: routing is a RoutingSource interface with a documented backend ladder — 'fixture' (this phase) → 'wasm-router' (OSS track, backend #2) → 'valhalla' (VPS, backend #3) — and NO runtime network call anywhere in the module
D-03: route() resolving to null IS the router-pending contract — an unknown destination or an origin outside the 200 m tolerance is null, never a fabricated geometry
D-04: the WASM-router backend #2 direction is preserved in the type contract's header (same interface, different name/impl — the SearchSource ladder pattern)
D-05: the module performs zero fetch/XMLHttpRequest/env reads — the Worker gains no route proxy and the web bundle gains no runtime routing dependency
D-07: the route model carries step geometry + snapped-progress support fields from day one (RouteStep typed, RouteGeometry.cumulativeDistanceM derived at load)
A route request for an unknown destinationId resolves to null — the UI-SPEC's pending state — never a nearest-match geometry
A route request whose origin sits more than 200 m from every pinned public origin for that destination resolves to null (haversine, ORIGIN_TOLERANCE_M)
The polyline6 codec round-trips EXACTLY on the 1e-6 grid: encodePolyline6(decodePolyline6(s)) === s for every committed fixture string (verified against a live OSRM demo geometry)
The routing module contains zero console.* calls, zero storage tokens, and zero network calls — nothing about a request outlives the caller's frame (D-15 analog)
The route model carries RouteStep[] and cumulativeDistanceM from day one; fixture-world steps are EMPTY arrays (honest 'not wired', never fabricated maneuvers — D-10)
The fixture index is built lazily on first route() and memoized — never at module scope (the gazetteer LAW 1, Workers 1 s startup)
path provides contains
routing/src/types.ts RoutePoint / RouteRequest / RouteSummary / RouteStep / RouteGeometry / RouteOption / RouteResult / RoutingSource contracts (D-01, D-03, D-07) export interface RoutingSource
path provides contains
routing/src/polyline.ts decodePolyline6 / encodePolyline6 — pure, zero-dependency codec (RESEARCH Finding 1) export function decodePolyline6
path provides contains
routing/src/geo.ts haversineM + deriveCumulativeDistanceM (snapped-progress support, D-07) export function haversineM
path provides contains
routing/src/fixture-source.ts createFixtureRoutingSource — backend #1 (D-02): lazy index, 200 m origin tolerance, best-first options, empty steps export function createFixtureRoutingSource
path provides contains
routing/src/data/route-fixtures.json the committed fixture dataset SCHEMA with an empty pairs array (plan 05-02 populates it via the build-time script) "pairs"
path provides contains
routing/src/index.ts the public API surface — imported by web (route surface, plan 05-04) and the corpus runner (plan 05-02) export function createFixtureRoutingSource
path provides
routing/test/polyline.test.mjs codec vectors (hand-computed + the external live-OSRM vector), round-trip law, malformed-input refusal
path provides
routing/test/fixture-source.test.mjs matching law, tolerance boundary, honest-null, lazy index, no-logging/no-storage gates
path provides
routing/test/loader-env.test.mjs the three-environment loader proof (bare Node import + esbuild bundle marker) — the Phase 4 top-mechanical-risk pattern
from to via pattern
routing/src/fixture-source.ts routing/src/data/route-fixtures.json static JSON import (build-time inlined by esbuild AND Vite, importable by bare Node 24) — never a runtime fetch route-fixtures.json
from to via pattern
routing/src/fixture-source.ts routing/src/polyline.ts options decode their geometry once, lazily, memoized per pair decodePolyline6
from to via pattern
routing/src/fixture-source.ts routing/src/geo.ts the 200 m tolerance match is haversineM(origin, pinnedOrigin) <= ORIGIN_TOLERANCE_M haversineM
from to via pattern
routing/src/index.ts routing/src/types.ts the public surface re-exports the contract types downstream plans import export type
Create the fifth npm workspace `routing/` (`@araharah/routing`) — the search-shaped RoutingSource seam (D-01) with backend #1, the fixture backend, and the polyline6 codec — so plans 05-02 (fixtures), 05-04 (web route surface), and 05-05 (gates) all build on one contract.

Purpose: the pivot (CONTEXT D-01) replaces Valhalla-behind-a-proxy with a RoutingSource interface whose first backend serves build-time-precomputed fixture routes for 10–20 golden KL pairs. This plan lands the seam, the codec (RESEARCH Finding 1), the geo math, the fixture-backend matching law (200 m origin tolerance, honest-null pending state D-03), the committed-but-empty fixture dataset schema, and the three-environment loader proof — the exact pattern Phase 4's search/ workspace proved.

Output: a buildless workspace (pure .ts + .json), wired into the root package.json workspaces + delegate scripts, with RED→GREEN test suites for the contract, codec, source, and loader.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@AGENTS.md @.planning/phases/05-routing-route-display/05-CONTEXT.md @.planning/phases/05-routing-route-display/05-RESEARCH.md @.planning/phases/05-routing-route-display/05-PATTERNS.md @search/src/types.ts @search/src/source.ts @search/src/gazetteer.ts @search/package.json @search/tsconfig.json @package.json @bot/src/security/initdata.ts Key contracts the executor needs. `RoutingSource` and the route model are given VERBATIM here — downstream plans (05-02 corpus, 05-04 web surface) import these exact names.

From routing/src/types.ts (this plan's output — the contract everything else implements against):

/** A WGS84 point. */
export interface RoutePoint {
  lat: number;
  lng: number;
}

/** A routing request. The origin is the live fix (memory only — GEO-03/D-07). */
export interface RouteRequest {
  origin: RoutePoint;
  /** The gazetteer id resolved by the Phase 4 destination resolver. */
  destinationId: string;
}

/** Distance/duration summary. Distances in meters, durations in seconds. */
export interface RouteSummary {
  distanceM: number;
  durationS: number;
}

/**
 * A maneuver step — DAY-ONE FIELD (D-07, GUID-05/06 upstream obligation).
 * Fixture-world steps are ALWAYS empty arrays (D-10 defers instructions to
 * Phase 6): an empty array is honest "not wired"; a fabricated step list
 * would not be. Real step geometry arrives with backends #2/#3.
 */
export interface RouteStep {
  distanceM?: number;
  durationS?: number;
  name?: string;
  /** polyline6-encoded step geometry (backend #2/#3 populate this). */
  geometry?: string;
}

/** Decoded geometry + the snapped-progress support field (D-07). */
export interface RouteGeometry {
  points: RoutePoint[];
  /** Same length as points; [0] === 0; non-decreasing. Meters along the line. */
  cumulativeDistanceM: number[];
}

/** One routable alternative. options[0] of a result is the BEST route. */
export interface RouteOption {
  geometry: RouteGeometry;
  summary: RouteSummary;
  steps: RouteStep[];
}

export interface RouteResult {
  destinationId: string;
  origin: RoutePoint;
  /** 1..3 options, index 0 = best (order preserved from the backend). */
  options: RouteOption[];
  /** === the RoutingSource name that produced it. */
  backend: string;
}

/**
 * The pluggable routing seam (D-01). Backends: "fixture" (this phase) →
 * "wasm-router" (OSS track, backend #2 — client-side, origin+destination
 * never touch a server) → "valhalla" (VPS, backend #3). Backends #2/#3 do
 * NOT ship in this phase — the interface exists so they slot in without a
 * client rewrite, exactly parallel to SearchSource's ladder (D-03/D-04).
 *
 * 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.
 *
 * No logging, no storage, no network (D-05/D-12/D-15 analog): request
 * coordinates never leave the caller's frame.
 */
export interface RoutingSource {
  readonly name: string;
  route(req: RouteRequest): Promise<RouteResult | null>;
}

From routing/src/fixture-source.ts (this plan's output):

/** A pair may serve a request only when the origin sits within this many
 *  meters (haversine) of the pair's pinned public origin (D-02/D-03). */
export const ORIGIN_TOLERANCE_M = 200;

export function createFixtureRoutingSource(
  data?: RouteFixturesFile, // optional injection — tests pass synthetic datasets
): RoutingSource; // { name: "fixture", route(req) }

/** pairId -> pinned origin, or null when unknown (the 05-05 harness knob
 *  ?fixpair=<pair-id> reads this; public data only). */
export function getPairOrigin(pairId: string): RoutePoint | null;

/** Introspection for the corpus runner (plan 05-02) — lazy, memoized. */
export function getPairs(): readonly FixturePair[];

The fixture JSON schema (both the seed file this plan commits AND the shape routing/test/fixture-source.test.mjs injects):

{
  "_license": "ODbL-1.0",
  "_attribution": "© OpenStreetMap contributors",
  "_notice": "Route geometry © OpenStreetMap contributors, precomputed from the public OSRM demo server (router.project-osrm.org) at build time between fixed public landmark coordinates. ODbL v1.0 — https://opendatacommons.org/licenses/odbl/1-0/",
  "_source": "OSRM demo server (router.project-osrm.org) — build-time fetch, never a runtime dependency",
  "version": 1,
  "originToleranceM": 200,
  "pairs": []
}

FixturePair (typed in types.ts):

export interface FixtureRoute {
  /** polyline6, stored VERBATIM from the OSRM demo response. */
  geometry: string;
  distanceM: number;
  durationS: number;
}

export interface FixturePair {
  /** "<origin-id>-to-<dest-id>", slug charset — also the ?fixpair= knob value. */
  pairId: string;
  origin: { lat: number; lng: number; label: string };
  destinationId: string;
  /** Best-first, 1..3 entries (OSRM demo returns best-first; preserved). */
  routes: FixtureRoute[];
}

The module header-contract style every new file copies (bot/src/security/initdata.ts):

/*
 * <file> — <one-line purpose> (<decision/requirement IDs>).
 * ---...
 * <the structural laws this module obeys, citing D-xx>
 */

<environment_facts> Verified in this session — do not re-derive, do not spend a task re-probing:

  • Node is v24.18.0, npm 11.16.0. Node 24 strips TypeScript types by DEFAULT, so a .mjs test CAN await import("../src/foo.ts") — the repo's existing pattern (search/test/corpus.test.mjs does exactly this).
  • Static JSON import with the with { type: "json" } attribute WORKS on this Node (probed in Phase 4; web/test/build-artifact.test.mjs line 47 does exactly this).
  • The root package.json workspaces array is currently ["theme","bot","web","search"] — this plan appends "routing".
  • search/tsconfig.json is the platform-neutral tsconfig to copy (resolveJsonModule: true, no DOM/Workers types).
  • The polyline6 codec reference implementation round-trips in this session AND re-encodes a live OSRM demo geometry byte-identically (encode(decode("ktv_Egu~~EsA…")) === original). Derived vectors are pinned in <polyline_vectors>` below.
  • A LIVE probe of the OSRM demo server (2026-10-24, KLCC→KL Sentral, public coordinates) returned code: Ok, 2 routes, 6907.9 m / 663.2 s, polyline6 geometry — the external vector in the codec suite is real data, not invented. </environment_facts>

<polyline_vectors> Hand-derived vectors the codec suite MUST assert (computed with the reference algorithm in this session; the suite's own implementation must reproduce them — RED first):

  • decodePolyline6("??") → [{ lat: 0, lng: 0 }]; encodePolyline6([{ lat: 0, lng: 0 }]) → "??" (the zero-delta case)
  • encodePolyline6([{ lat: 3.1578, lng: 101.7117 }]) → "oqv_Egt~~`E" (NOTE: contains a backtick — mind the quoting in both the assertion and the plan's grep checks)
  • encodePolyline6([{ lat: 3.1578, lng: 101.7117 }, { lat: 3.1579, lng: 101.7118 }, { lat: 3.158, lng: 101.712 }]) → "oqv_Egt~~`EgEgEgEoK" (delta accumulation across 3 points)
  • encodePolyline6([{ lat: -1.5, lng: 2.25 }, { lat: 0.75, lng: -0.125 }]) → "~tpzA_pihC_pihCnt}oC" (negative-value zigzag: ~ inversion branch)
  • EXTERNAL VECTOR (live OSRM demo geometry, KLCC→KL Sentral, 2026-10-24): decodePolyline6("ktv_Egu~~EsAlFaDnGeFEmGvEmWbSySjOiTfUiOb@{n@xZ}FhBsGx@cJ?")[0]deep-equals{ lat: 3.157846, lng: 101.711716 }(OSRM snapped the origin onto the road graph — the small offset from 3.1578/101.7117 is expected), andencodePolyline6(decodePolyline6())` === the original string.
  • decodePolyline6("") → [] (empty is legal); decodePolyline6("A") THROWS (truncated varint — never a NaN point); any odd-length or dangling-continuation input THROWS.
  • ROUND-TRIP LAW: for every string above, encodePolyline6(decodePolyline6(s)) === s. </polyline_vectors>

<matching_law> The fixture backend's contract (fixture-source.ts):

  1. Slug guard first (mirror search/src/source.ts ENTRY_ID_PATTERN): destinationId must match /^[a-z0-9-]{1,64}$/ or the request resolves to null before the index is touched.
  2. Lazy index (gazetteer LAW 1): on first route() call, build Map<pairId, FixturePair> + a destinationId → pairs multimap ONCE, memoized in module scope of the factory closure. route-fixtures.json is parsed by the STATIC IMPORT at module-eval of fixture-source.ts… NO — the static import gives the raw object but the INDEX (Maps, decoded geometry) must be built lazily: module scope holds only the import binding; every Map/decodePolyline6 call happens inside first-use functions.
  3. Match: candidates = pairs whose destinationId equals the request's; among them, the FIRST (in file order) whose haversineM(req.origin, pair.origin) <= ORIGIN_TOLERANCE_M (200) serves. None → null.
  4. Shape: options = pair.routes mapped best-first: geometry decoded via decodePolyline6, cumulativeDistanceM via deriveCumulativeDistanceM (starts at 0, non-decreasing, meters), steps: [] ALWAYS (D-10 — a fixture-world step list would be fabricated), summary verbatim from the fixture. backend: "fixture".
  5. Defensive validation (the bot/src/kv/location-pin.ts precedent): a malformed pair (non-finite distance/duration, geometry that throws on decode, empty routes array, routes > 3) is SKIPPED at index-build time — it renders as an honest null, never a throw to the caller, never a NaN coordinate. (Plan 05-02's corpus gate makes malformed pairs unshippable in practice; this is the runtime backstop.)
  6. Single-route results are normal: 1 option → the web surface renders no chip row (UI-SPEC). 2–3 options → index 0 is the best.
  7. getPairOrigin(pairId) returns the pinned origin or null (slug-guarded) — the harness knob (plan 05-05) and tests read this; it never consults the network. </matching_law>

<threat_model>

Trust Boundaries

Boundary Description
Live fix (request origin) → fixture source The origin is the user's position in memory. It must never reach a log, a storage surface, or a network call — the module's three zero-laws are the mitigation surface.
Committed fixture JSON → runtime index Fixtures are trusted repo content, but a malformed entry (non-finite distance, truncated polyline) would poison the map with NaN or throw in the webview's render path.
OSRM-encoded geometry → decoder Decoded strings are external data at heart (built from a third-party demo server at build time); a decoder that returns partial/NaN points on malformed input converts data rot into a broken map draw.
Module scope → Workers/browser startup Index construction or geometry decoding at module scope costs startup latency in every environment that imports the package (the Workers 1 s limit; the webview first paint).

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-05-01 Information Disclosure Request origin coordinates reaching a log line or storage surface (D-12/D-15 analog) mitigate The module contains NO console.* and NO storage token anywhere (gate C4 strips comments then greps — the web/test/no-storage.test.mjs comment-stripping pattern), takes no logger parameter, and performs no fetch. The origin lives in the caller's frame and dies with it.
T-05-02 Tampering Malformed polyline (truncated varint, odd length) decoding to NaN/partial coordinates that poison the map draw mitigate decodePolyline6 throws on truncated/dangling input (never returns partial points) — gate P7 asserts decode("A") throws; the fixture backend treats a throwing geometry as a malformed pair and skips it (matching_law #5), so the webview sees null (the pending card), never a NaN LngLat.
T-05-03 Denial of Service Hostile request shape (100 KB destinationId, wrong types) forcing unbounded work mitigate The slug guard (/^[a-z0-9-]{1,64}$/) rejects before the index is touched (gate F4); the index is a prebuilt Map; per-request work is O(matching pairs) with a ≤ 3-route projection — bounded by the committed dataset size, not the request.
T-05-04 Spoofing / Elevation of Privilege A config/env switch substituting a different backend to bypass the honest-pending contract mitigate No env reads, no config reads, no dynamic backend registry — the backend is selected IN CODE at the import site (plan 05-04 constructs createFixtureRoutingSource() directly). Backend #2/#3 are documented ladder entries (type header), not runtime switches.
T-05-06 Repudiation Fixture geometry shipped without the ODbL attribution (OSM derivative data) mitigate The _license/_attribution/_notice header travels INSIDE the JSON (the gazetteer's three-surfaces pattern); gate C6 asserts the seed file carries all three fields and the notice names ODbL v1.0.
T-05-07 Tampering Fabricated turn instructions passing as real maneuver data (D-10 violation — the app pretending guidance exists) mitigate RouteOption.steps is typed but fixtures ALWAYS produce steps: [] (matching_law #4) — gate F5 asserts every served option's steps array is empty; the UI surface (plan 05-04) renders no instruction from it. Day-one fields exist for Phase 6's real consumer (D-07), never for fake data.
</threat_model>
task 1: workspace scaffold + type contracts + route model day-one fields package.json, routing/package.json, routing/tsconfig.json, routing/src/types.ts, routing/test/runner.mjs, routing/test/harness.mjs, routing/test/contract.test.mjs - `package.json` (root — `workspaces` is `["theme","bot","web","search"]`; append `"routing"`) - `search/package.json` (the manifest shape to copy — `name`, `private`, `type: "module"`, zero runtime deps, `test`/`typecheck` scripts) - `search/tsconfig.json` (the platform-neutral compilerOptions to copy verbatim) - `search/src/types.ts` (the contract-file style: header comment naming the backend ladder, empty-value-is-load-bearing law) - `search/test/runner.mjs` + `search/test/harness.mjs` (copy BOTH verbatim — the runner auto-discovers `*.test.mjs` via `import.meta.dirname`) - `bot/test/initdata.test.mjs` (the lazy-module-load + letter-numbered-vector suite style the contract suite copies) **Root `package.json`.** Append `"routing"` to `workspaces` → `["theme","bot","web","search","routing"]`. Add delegate scripts (existing naming convention):
"routing:test": "npm test -w routing",
"routing:typecheck": "npm run typecheck -w routing",
"routing:corpus": "node routing/scripts/route-corpus.mjs"

(routing:corpus points at the script plan 05-02 creates — the delegate lands NOW so the root script surface is stable. Do NOT touch build/deploy/dev — this workspace is buildless, the search/ precedent.)

routing/package.json — copy search/package.json's shape, renamed:

{
  "name": "@araharah/routing",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node test/runner.mjs",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.9.3"
  }
}

NO exports/main (the @araharah/theme + @araharah/search precedent — consumers import the explicit subpath with extension). NO runtime dependencies.

routing/tsconfig.json — copy search/tsconfig.json VERBATIM (it already has resolveJsonModule: true, allowImportingTsExtensions, verbatimModuleSyntax, noUncheckedIndexedAccess, strict, no DOM/Workers lib).

routing/test/runner.mjs + routing/test/harness.mjs — copy search/test/runner.mjs and search/test/harness.mjs VERBATIM (zero-dependency harness; no Jest/Vitest/Mocha — the repo law).

routing/src/types.ts — implement the <interfaces> block EXACTLY (RoutePoint, RouteRequest, RouteSummary, RouteStep, RouteGeometry, RouteOption, RouteResult, RoutingSource, FixtureRoute, FixturePair) with the header comment naming the D-01 backend ladder and the D-03 null contract. The day-one fields (D-07) are RouteStep (typed, optional geometry) and RouteGeometry.cumulativeDistanceM — their doc comments must say "GUID-05/06 upstream obligation" verbatim so the type gate (C2) can grep them.

routing/test/contract.test.mjs — RED-first, lazy-import style (let typesPromise = null; function loadTypes() { … import("../src/types.ts") … }). Cases:

  • C1 interface shape — types.ts exports RoutingSource whose route returns a Promise; assert via a stub object satisfying the interface compiled by Node's type-stripping (structural: { name: "x", route: async () => null } is assignable by shape — assert typeof stub.route === "function" and stub.route({origin:{lat:0,lng:0},destinationId:"x"}) instanceof Promise).
  • C2 day-one model fields (D-07/D-11 type gate) — read routing/src/types.ts AS TEXT and assert it contains cumulativeDistanceM, steps: RouteStep[], and the literal string GUID-05/06 upstream obligation (the field-level contract is pinned by test, not by hope).
  • C3 null contract — the RoutingSource doc comment contains router pending (the D-03 semantics are contract documentation, grep-pinned).
  • C4 no-logging/no-storage/no-network — for EVERY file matching routing/src/*.ts: strip block+line comments (web/test/no-storage.test.mjs stripComments pattern) and assert ZERO occurrences of console., localStorage, sessionStorage, indexedDB, document.cookie, .put(, fetch(, XMLHttpRequest, process.env (gate grows with plan 05-02's script — scripts/ live OUTSIDE src/ and are exempt).
  • C5 zero runtime dependencies — routing/package.json parses with NO dependencies field (D-05: nothing in the Worker/browser bundle path).
  • C6 ODbL header present — routing/src/data/route-fixtures.json parses and carries _license: "ODbL-1.0", _attribution containing OpenStreetMap, _notice containing ODbL, and version: 1 (T-05-06).

Create routing/src/data/route-fixtures.json with exactly the seed schema from the <interfaces> block — pairs: [] (plan 05-02's script populates it; the schema + ODbL header ship NOW so the loader proof has real bytes).

Acceptance: npm run routing:test exits 0 with PASS C1…PASS C6; npm run routing:typecheck exits 0. cd /home/C-Fu/araharah && npm install --package-lock-only --workspaces 2>&1 | tail -3 ; npm run routing:test 2>&1 | tail -15 ; npm run routing:typecheck 2>&1 | tail -5 ; node -e "const w=require('./package.json').workspaces; if(!w.includes('routing')) throw new Error('routing workspace missing'); console.log('OK')" <acceptance_criteria> - node -e "const w=require('./package.json').workspaces; if(!w.includes('routing')) throw new Error(1); console.log('OK')" exits 0 - grep -q '"routing:test": "npm test -w routing"' package.json exits 0 AND grep -q '"routing:corpus": "node routing/scripts/route-corpus.mjs"' package.json exits 0 - test -f routing/package.json && test -f routing/tsconfig.json && test -f routing/test/runner.mjs && test -f routing/test/harness.mjs exits 0 - node -e "const p=require('./routing/package.json'); if(p.name!=='@araharah/routing') throw new Error(1); if(p.dependencies) throw new Error('zero runtime deps'); console.log('OK')" exits 0 - npm run routing:test exits 0 and the output contains PASS C1 through PASS C6 - npm run routing:typecheck exits 0 - grep -q 'export interface RoutingSource' routing/src/types.ts exits 0 AND grep -q 'cumulativeDistanceM' routing/src/types.ts exits 0 AND grep -q 'GUID-05/06 upstream obligation' routing/src/types.ts exits 0 - node -e "const f=require('./routing/src/data/route-fixtures.json'); if(!Array.isArray(f.pairs)||f.pairs.length!==0) throw new Error(1); if(f._license!=='ODbL-1.0') throw new Error(2); console.log('OK')" exits 0 </acceptance_criteria> The routing/ workspace exists as a buildless fifth npm workspace with the RoutingSource contract (D-01 ladder + D-03 null semantics + D-07 day-one fields), the committed empty fixture schema with ODbL header, and a 6-case contract suite green.

task 2: polyline6 codec (pure, zero-dependency) routing/src/polyline.ts, routing/test/polyline.test.mjs - `routing/src/types.ts` (task 1's output — `RoutePoint` is the codec's in/out shape) - `.planning/phases/05-routing-route-display/05-RESEARCH.md` (Finding 1 — the verbatim-storage decision and the external OSRM vector provenance) - `search/src/distance.ts` (the "pure, zero imports, header comment" module style) **`routing/src/polyline.ts`** — the Google encoded-polyline algorithm at precision 1e6 (polyline6 — what the OSRM demo's `geometries=polyline6` returns). Exports:
export function decodePolyline6(encoded: string): RoutePoint[];
export function encodePolyline6(points: readonly RoutePoint[]): string;

Implementation laws:

  • Pure: ZERO imports, ZERO I/O (the search/src/distance.ts header-comment style; cite RESEARCH Finding 1).
  • Standard algorithm: per coordinate, delta from previous value (Math.round(value * 1e6) - prev), zigzag (v < 0 ? ~(v << 1) : (v << 1)), 5-bit varint chunks with continuation bit 0x20, chunk + 63 as the output char. Lat then lng per point.
  • Decode MUST throw on malformed input — a dangling continuation chunk (string ends mid-varint) or odd trailing coordinate: throw new Error("truncated polyline6 input") (T-05-02: never return partial or NaN points). "" decodes to [] (legal empty).
  • Round-trip property: values live on the 1e-6 grid (decode divides integers by 1e6), so encode(decode(s)) === s for any valid string — the corpus gate (plan 05-02) leans on this.

routing/test/polyline.test.mjs — RED-first. Cases (vectors from the <polyline_vectors> block — they are computed ground truth, not guesses):

  • P1 zero vector — decode("??") → [{lat:0,lng:0}]; encode round-trip.
  • P2 KL point vector — encode of [{lat:3.1578,lng:101.7117}] === "oqv_Egt~~`E" and decode inverts exactly.
  • P3 delta accumulation — the 3-point KL vector (encode string from <polyline_vectors>); decode returns the 3 points exactly.
  • P4 negative zigzag — the V4 vector (asserts the ~ inversion branch).
  • P5 external OSRM vector — decode of the live-probe string's first point deep-equals {lat:3.157846,lng:101.711716}; encode(decode(s)) === s for the full string (external ground truth, RESEARCH Finding 1).
  • P6 empty input — decode("") → []; encode([]) → "".
  • P7 malformed refusal — decode("A") throws; decode("g") throws (dangling continuation); neither returns a point (T-05-02).
  • P8 round-trip law — for EVERY vector string in this suite: encode(decode(s)) === s.

Acceptance: npm run routing:test exits 0 with PASS P1…PASS P8 (plus task 1's C-cases still green). cd /home/C-Fu/araharah && npm run routing:test 2>&1 | tail -15 ; npm run routing:typecheck 2>&1 | tail -5 <acceptance_criteria> - npm run routing:test exits 0 and the output contains PASS P1 through PASS P8 - npm run routing:typecheck exits 0 - grep -q 'export function decodePolyline6' routing/src/polyline.ts exits 0 AND grep -q 'export function encodePolyline6' routing/src/polyline.ts exits 0 - node -e "import('./routing/src/polyline.ts').then(m=>{ if(JSON.stringify(m.decodePolyline6('??'))!==JSON.stringify([{lat:0,lng:0}])) throw new Error('P1'); if(m.encodePolyline6([{lat:3.1578,lng:101.7117}])!=='oqv_Egt~~\E') throw new Error('P2'); try{m.decodePolyline6('A'); throw new Error('should-throw');}catch(e){} console.log('codec OK')})"exits 0 -grep -v '^\s*//' routing/src/polyline.ts | grep -c 'from "' | head -1` returns 0 (ZERO imports — the distance.ts precedent) </acceptance_criteria> The polyline6 codec exists as a pure zero-dependency module proven by 8 named cases including the external live-OSRM vector and the malformed-input refusal law.

task 3: geo math + fixture backend + lazy loader + public API + three-environment proof routing/src/geo.ts, routing/src/fixture-source.ts, routing/src/index.ts, routing/test/fixture-source.test.mjs, routing/test/loader-env.test.mjs - `search/src/gazetteer.ts` (LAW 1 lazy-memoized index, LAW 2 static JSON import, LAW 3 no logging/storage — copied as a posture, adapted to route data) - `search/src/source.ts` (the slug-guard-first ordering + deterministic shape of a backend implementation) - `search/test/loader-env.test.mjs` (the three-environment proof to mirror: bare-Node import + esbuild bundle marker) - `search/src/index.ts` (the public-API surface style: named exports only, subpath-with-extension import law in the header) - `routing/src/types.ts` + `routing/src/polyline.ts` (tasks 1–2 outputs this plan builds on) **`routing/src/geo.ts`** — two pure functions, zero imports:
export function haversineM(a: RoutePoint, b: RoutePoint): number;
export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[];
  • haversineM: standard great-circle distance, earth radius 6_371_000 m, returns meters (finite for finite inputs).
  • deriveCumulativeDistanceM: [0, d(p0→p1), d(p0→p1)+d(p1→p2), …] — same length as input, [0] === 0, non-decreasing; single point → [0]; empty → []. This is the snapped-progress support field (D-07): Phase 6's guidance snaps a fix's progress along the line using this array instead of re-deriving it.

routing/src/fixture-source.ts — backend #1 per the <matching_law> block: ORIGIN_TOLERANCE_M = 200, createFixtureRoutingSource(data?), getPairOrigin(pairId), getPairs(). Static import of the seed JSON (import fixturesData from "./data/route-fixtures.json" with { type: "json" }; — the data parameter overrides it for tests). Header comment: name the D-02/D-03/D-12 laws and the 200 m tolerance rationale (GPS jitter around a known landmark, never a fabricated route for an unknown origin).

routing/src/index.ts — named exports ONLY, header documenting the subpath-with-extension import law (import { createFixtureRoutingSource } from "@araharah/routing/src/index.ts"; — a bare @araharah/routing specifier resolves to NOTHING):

export type {
  RoutePoint, RouteRequest, RouteSummary, RouteStep,
  RouteGeometry, RouteOption, RouteResult, RoutingSource,
  FixturePair, FixtureRoute,
} from "./types.ts";
export { decodePolyline6, encodePolyline6 } from "./polyline.ts";
export { haversineM, deriveCumulativeDistanceM } from "./geo.ts";
export {
  ORIGIN_TOLERANCE_M,
  createFixtureRoutingSource,
  getPairOrigin,
  getPairs,
} from "./fixture-source.ts";

routing/test/fixture-source.test.mjs — RED-first, injecting SYNTHETIC datasets via createFixtureRoutingSource(data) (never the network, never the committed file). Build one synthetic pair fixture in the test: pairId "kl-tower-to-kl-sentral", origin {lat:3.1528,lng:101.7038,label:"Kuala Lumpur Tower"}, destinationId "kl-sentral", two routes whose geometry is produced by encodePolyline6 over small point paths around KL Sentral, distanceM/durationS sane (e.g. 4_800 / 600). Cases:

  • F1 empty corpus → null — createFixtureRoutingSource({version:1,originToleranceM:200,pairs:[]}) → every route() resolves null (the committed seed's state until 05-02 fills it).
  • F2 match serves best-first — request with the pinned origin + destinationId resolves a result with backend:"fixture", 2 options, option 0 === fixture route order preserved, geometry points decode to the synthetic path, cumulative array starts at 0 and is non-decreasing, steps deep-equals [] for every option (T-05-07).
  • F3 tolerance boundary — origin offset ~0.001 deg lat (≈111 m) from the pinned origin → serves; offset ~0.002 deg (≈222 m) → null (the 200 m law, haversine-verified).
  • F4 slug guard — destinationId "not a slug!" and "".padEnd(65,"a") → null WITHOUT consulting the index (T-05-03).
  • F5 unknown destination → null — a valid slug with no pair → null (the D-03 honest pending; never a nearest-match).
  • F6 malformed pair skipped — a dataset containing a pair with distanceM: Number.NaN and a pair with an empty routes array serves null (both skipped) while a well-formed sibling pair still serves — defensive validation, never a throw.
  • F7 lazy index — source-text gate: fixture-source.ts with comments stripped has NO new Map( and NO decodePolyline6( call at TOP LEVEL (all inside functions — the gazetteer LAW 1 posture, Workers 1 s startup + webview first paint).
  • F8 getPairOrigin — resolves the synthetic pair's pinned origin by pairId; unknown/garbage pairId → null.

routing/test/loader-env.test.mjs — the three-environment proof (Phase 4's top-mechanical-risk pattern):

  • L1 bare-Node static JSON import — import fixtures from "../src/data/route-fixtures.json" with { type: "json" } works under Node 24 type-stripping and exposes pairs.
  • L2 esbuild bundle marker — node_modules/.bin/esbuild routing/src/index.ts --bundle --format=esm --outfile=/tmp/routing-bundle.mjs (run via execFileSync or spawnSync from the test) exits 0 AND the bundle text contains the seed marker router.project-osrm.org (the _source header survives inlining) — Vite uses the same esbuild inlining, so the marker proves the browser path; L1 proves the corpus-runner path.
  • L3 the empty seed serves null — the DEFAULT createFixtureRoutingSource() (no injection) resolves null for a well-formed KL request (proves the committed file parses through the whole path).

Acceptance: npm run routing:test exits 0 with PASS F1…PASS F8 + PASS L1…PASS L3; npm run routing:typecheck exits 0. cd /home/C-Fu/araharah && npm run routing:test 2>&1 | tail -20 ; npm run routing:typecheck 2>&1 | tail -5 ; ls node_modules/.bin/esbuild >/dev/null && echo "esbuild present" <acceptance_criteria> - npm run routing:test exits 0 and the output contains PASS F1 through PASS F8 and PASS L1 through PASS L3 - npm run routing:typecheck exits 0 - grep -q 'export function createFixtureRoutingSource' routing/src/fixture-source.ts exits 0 AND grep -q 'ORIGIN_TOLERANCE_M = 200' routing/src/fixture-source.ts exits 0 - grep -q 'route-fixtures.json' routing/src/fixture-source.ts exits 0 (the static import is present — LAW 2) - grep -q 'export function haversineM' routing/src/geo.ts exits 0 AND grep -q 'export function deriveCumulativeDistanceM' routing/src/geo.ts exits 0 - node -e "import('./routing/src/index.ts').then(m=>{ if(m.ORIGIN_TOLERANCE_M!==200) throw new Error(1); if(typeof m.createFixtureRoutingSource!=='function') throw new Error(2); if(typeof m.getPairOrigin!=='function') throw new Error(3); console.log('index OK')})" exits 0 - node -e "import('./routing/src/geo.ts').then(m=>{const c=m.deriveCumulativeDistanceM([{lat:0,lng:0},{lat:0.001,lng:0}]); if(c.length!==2||c[0]!==0||!(c[1]>100&&c[1]<120)) throw new Error('cumulative math'); console.log('geo OK')})" exits 0 - grep -q 'router.project-osrm.org' routing/src/data/route-fixtures.json exits 0 (the loader marker exists in the committed seed) </acceptance_criteria> The fixture RoutingSource backend exists with the 200 m matching law, honest-null pending semantics, defensive pair validation, lazy index, and a proven three-environment loader — the complete backend #1 of the D-01 ladder.

1. `npm run routing:test` — all C/P/F/L cases green (exit 0). 2. `npm run routing:typecheck` — exit 0. 3. Root delegates: `npm run routing:test` and `npm run routing:typecheck` work from the repo root; `workspaces` contains `routing`. 4. `grep -c 'console\.' routing/src/*.ts` → 0 across every src file (comments stripped by the C4 gate). 5. `node -e "import('./routing/src/index.ts').then(m=>m.createFixtureRoutingSource().route({origin:{lat:3.1528,lng:101.7038},destinationId:'kl-sentral'}).then(r=>{ if(r!==null) throw new Error('seed must serve null until 05-02 fills fixtures'); console.log('honest-null OK')}))"` — the empty committed corpus is honest.

<success_criteria>

  • The @araharah/routing workspace compiles clean and its 17-case suite passes from the repo root.
  • The RoutingSource contract, codec, geo math, fixture backend, and public API all exist with the exact names downstream plans import.
  • The committed seed fixture file carries the ODbL header and serves honest nulls.
  • Zero runtime dependencies, zero network, zero logging, zero storage in routing/src/. </success_criteria>