chore(planning): phase 5/6/7 planning artifacts + milestone-completion state sync

This commit is contained in:
araharah-executor
2026-10-25 12:51:27 +08:00
parent bb587ab537
commit db27aa8440
34 changed files with 3949 additions and 214 deletions
@@ -0,0 +1,526 @@
---
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>