Files

527 lines
39 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: 05-routing-route-display
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
requirements: [RTNG-01]
must_haves:
decisions_implemented:
- "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)"
truths:
- "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)"
artifacts:
- path: "routing/src/types.ts"
provides: "RoutePoint / RouteRequest / RouteSummary / RouteStep / RouteGeometry / RouteOption / RouteResult / RoutingSource contracts (D-01, D-03, D-07)"
contains: "export interface RoutingSource"
- path: "routing/src/polyline.ts"
provides: "decodePolyline6 / encodePolyline6 — pure, zero-dependency codec (RESEARCH Finding 1)"
contains: "export function decodePolyline6"
- path: "routing/src/geo.ts"
provides: "haversineM + deriveCumulativeDistanceM (snapped-progress support, D-07)"
contains: "export function haversineM"
- path: "routing/src/fixture-source.ts"
provides: "createFixtureRoutingSource — backend #1 (D-02): lazy index, 200 m origin tolerance, best-first options, empty steps"
contains: "export function createFixtureRoutingSource"
- path: "routing/src/data/route-fixtures.json"
provides: "the committed fixture dataset SCHEMA with an empty pairs array (plan 05-02 populates it via the build-time script)"
contains: "\"pairs\""
- path: "routing/src/index.ts"
provides: "the public API surface — imported by web (route surface, plan 05-04) and the corpus runner (plan 05-02)"
contains: "export function createFixtureRoutingSource"
- path: "routing/test/polyline.test.mjs"
provides: "codec vectors (hand-computed + the external live-OSRM vector), round-trip law, malformed-input refusal"
- path: "routing/test/fixture-source.test.mjs"
provides: "matching law, tolerance boundary, honest-null, lazy index, no-logging/no-storage gates"
- path: "routing/test/loader-env.test.mjs"
provides: "the three-environment loader proof (bare Node import + esbuild bundle marker) — the Phase 4 top-mechanical-risk pattern"
key_links:
- from: "routing/src/fixture-source.ts"
to: "routing/src/data/route-fixtures.json"
via: "static JSON import (build-time inlined by esbuild AND Vite, importable by bare Node 24) — never a runtime fetch"
pattern: "route-fixtures.json"
- from: "routing/src/fixture-source.ts"
to: "routing/src/polyline.ts"
via: "options decode their geometry once, lazily, memoized per pair"
pattern: "decodePolyline6"
- from: "routing/src/fixture-source.ts"
to: "routing/src/geo.ts"
via: "the 200 m tolerance match is haversineM(origin, pinnedOrigin) <= ORIGIN_TOLERANCE_M"
pattern: "haversineM"
- from: "routing/src/index.ts"
to: "routing/src/types.ts"
via: "the public surface re-exports the contract types downstream plans import"
pattern: "export type"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
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):
```typescript
/** 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):
```typescript
/** 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):
```json
{
"_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):
```typescript
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`):
```typescript
/*
* <file> — <one-line purpose> (<decision/requirement IDs>).
* ---...
* <the structural laws this module obeys, citing D-xx>
*/
```
</interfaces>
<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~~`EsAlFaDnGeF`EmGvEmWbSySjOiTfUiOb`@{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), and `encodePolyline6(decodePolyline6(<that string>))` === 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>
<tasks>
<task type="auto" tdd="true">
<name>task 1: workspace scaffold + type contracts + route model day-one fields</name>
<files>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</files>
<read_first>
- `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)
</read_first>
<action>
**Root `package.json`.** Append `"routing"` to `workspaces` → `["theme","bot","web","search","routing"]`. Add delegate scripts (existing naming convention):
```json
"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:
```json
{
"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.
</action>
<verify>
<automated>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')"</automated>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: polyline6 codec (pure, zero-dependency)</name>
<files>routing/src/polyline.ts, routing/test/polyline.test.mjs</files>
<read_first>
- `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)
</read_first>
<action>
**`routing/src/polyline.ts`** — the Google encoded-polyline algorithm at precision 1e6 (polyline6 — what the OSRM demo's `geometries=polyline6` returns). Exports:
```typescript
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).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run routing:test 2>&1 | tail -15 ; npm run routing:typecheck 2>&1 | tail -5</automated>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: geo math + fixture backend + lazy loader + public API + three-environment proof</name>
<files>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</files>
<read_first>
- `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)
</read_first>
<action>
**`routing/src/geo.ts`** — two pure functions, zero imports:
```typescript
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):
```typescript
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.
</action>
<verify>
<automated>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"</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<verification>
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.
</verification>
<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>