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>
@@ -0,0 +1,304 @@
---
phase: 05-routing-route-display
plan: 02
type: execute
wave: 2
depends_on: ["01"]
files_modified:
- package.json
- routing/package.json
- routing/test/fixtures/golden-pairs.json
- routing/scripts/fetch-route-fixtures.mjs
- routing/scripts/route-corpus.mjs
- routing/src/data/route-fixtures.json
- routing/test/corpus.test.mjs
- routing/test/loader-env.test.mjs
autonomous: true
requirements: [RTNG-01, RTNG-02]
must_haves:
decisions_implemented:
- "D-02: fixture routes for the 10–20 golden KL pairs, geometries precomputed at BUILD time by a repo script hitting the public OSRM demo server once, committed as compact encoded polylines, each pair pinning distance/duration consistency gates"
- "D-12: fixture geometries are public-data routes between public places — the corpus gate mechanically proves every pinned origin equals a gazetteer landmark coordinate; the script header documents the maintainer-machine boundary"
- "D-11: the fixture payload stays inside the bundle budget — a fixture-level gzipped-size gate (≤ 100 KB) gives early warning under the 200 KB BA9 ceiling"
- "RTNG-02: the alternatives floor — at least 4 pairs carry ≥ 2 routes so the chips/redraw surface has real data"
truths:
- "15 golden pairs are committed in routing/src/data/route-fixtures.json, each with 1–3 best-first routes stored as verbatim polyline6 strings"
- "Every committed pair passes the OFFLINE tolerance gate: geometry↔summary haversine consistency (0.85–1.10 × distanceM), duration sanity (1.0–30 m/s average speed), and exact codec round-trip — no test ever touches a network"
- "Every pair's pinned origin coordinate equals SOME gazetteer entry's lat/lng within 1e-4 degrees — no user coordinate can have entered a fixture (mechanical D-12 proof)"
- "Every pair's destinationId exists in the shared gazetteer; exactly zero pairs target the pending-probe destination 'national-museum' (the 05-05 capture's pending-state carrier)"
- "The fetch script is a repo tool on the overpass-topup.mjs pattern: descriptive User-Agent, inter-request delay, per-pair hard-fail diagnostics, never imported at runtime, never run in CI or at web-build time"
- "The corpus gate is both a CLI (npm run routing:corpus) and an in-suite Nyquist gate (npm run routing:test) — one measurement core, two reporters, no drift"
artifacts:
- path: "routing/test/fixtures/golden-pairs.json"
provides: "the hand-authored pinned corpus: 15 pairs (origin landmark + destinationId + coordinates), the KL bbox, the size budget, the pending-probe id"
contains: "\"pairs\""
- path: "routing/scripts/fetch-route-fixtures.mjs"
provides: "the build-time OSRM-demo fetch tool (maintainer machine; D-12 boundary documented in header)"
contains: "router.project-osrm.org"
- path: "routing/scripts/route-corpus.mjs"
provides: "measurePairs() — the corpus measurement core the CLI prints and the suite asserts"
contains: "export function measurePairs"
- path: "routing/src/data/route-fixtures.json"
provides: "the COMMITTED fixture dataset — 15 pairs populated from the OSRM demo (this plan replaces 05-01's empty seed)"
contains: "\"pairId\""
- path: "routing/test/corpus.test.mjs"
provides: "the RT1–RT10 offline tolerance gate suite (in-process, deterministic)"
contains: "RT1"
key_links:
- from: "routing/scripts/fetch-route-fixtures.mjs"
to: "routing/test/fixtures/golden-pairs.json"
via: "the script reads the pinned corpus and fetches each pair from the OSRM demo"
pattern: "golden-pairs.json"
- from: "routing/scripts/fetch-route-fixtures.mjs"
to: "routing/src/data/route-fixtures.json"
via: "collect-then-write: the committed dataset is regenerated atomically per run"
pattern: "route-fixtures.json"
- from: "routing/scripts/route-corpus.mjs"
to: "routing/src/data/route-fixtures.json"
via: "the measurement core validates the committed dataset (never the script output directly)"
pattern: "route-fixtures.json"
- from: "routing/test/corpus.test.mjs"
to: "routing/scripts/route-corpus.mjs"
via: "the suite imports measurePairs and asserts the SAME numbers the CLI prints (gazetteer-corpus.mjs precedent)"
pattern: "measurePairs"
---
<objective>
Populate the committed fixture dataset with 10–20 real KL origin/destination pairs precomputed from the public OSRM demo server (build-time, public coordinates only), and pin them behind the offline corpus tolerance gate — the phase's primary acceptance instrument (the amended ROADMAP SC3: "the golden-route corpus routes cleanly through the fixture RoutingSource backend with bundle-size and type gates holding").
Purpose: D-02 makes fixture geometry the product's actual route data for v1's golden pairs; D-12 makes "public data between public places" a mechanically provable property (every pinned origin must equal a gazetteer landmark coordinate); RTNG-02 needs real alternatives (≥ 4 pairs with ≥ 2 routes) for the chips/redraw surface. This plan delivers the hand-authored pair list, the maintainer fetch script (overpass-topup.mjs pattern), the populated committed dataset, and the RT1–RT10 offline gate — both as a CLI and as an in-suite Nyquist gate.
Output: `routing/test/fixtures/golden-pairs.json` (15 pinned pairs), `routing/scripts/fetch-route-fixtures.mjs` (repo tool), a committed `routing/src/data/route-fixtures.json` with 15 real pairs, `routing/scripts/route-corpus.mjs` + `routing/test/corpus.test.mjs` (the gate), and the `routing:fixtures` root delegate.
</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
@.planning/phases/05-routing-route-display/05-01-PLAN.md
@search/scripts/overpass-topup.mjs
@search/scripts/gazetteer-corpus.mjs
@search/test/corpus.test.mjs
@search/src/data/kl-gazetteer.json
@routing/src/types.ts
@routing/src/index.ts
@routing/src/data/route-fixtures.json
<pinned_corpus>
The 15 golden pairs — hand-pinned NOW (the executor does not invent pairs; the executor verifies each pair routes via the script and records the outcome). All origins AND destinations are gazetteer entries from `search/src/data/kl-gazetteer.json` (public landmark coordinates; the D-12 proof keys off exactly this). Coordinates below are the gazetteer's own values — the corpus file repeats them verbatim so the file is self-contained, and gate RT2b re-proves they match the gazetteer.
| # | pairId | origin (id) | origin lat,lng | destinationId |
|---|--------|-------------|-----------------|---------------|
| 1 | kl-tower-to-kl-sentral | kl-tower | 3.1528, 101.7038 | kl-sentral |
| 2 | kl-sentral-to-suria-klcc | kl-sentral | 3.1343, 101.6869 | suria-klcc |
| 3 | suria-klcc-to-bangsar | suria-klcc | 3.1578, 101.7117 | bangsar |
| 4 | mid-valley-to-bukit-bintang | mid-valley | 3.118, 101.6779 | bukit-bintang |
| 5 | bukit-bintang-to-mid-valley | bukit-bintang | 3.1466, 101.7106 | mid-valley |
| 6 | pwtc-to-sunway-velocity | pwtc | 3.1768, 101.698 | sunway-velocity |
| 7 | masjid-jamek-to-lake-gardens | masjid-jamek | 3.1484, 101.6953 | lake-gardens |
| 8 | titiwangsa-to-petaling-street | titiwangsa | 3.1714, 101.7016 | petaling-street |
| 9 | sunway-pyramid-to-pavilion-kl | sunway-pyramid | 3.0726, 101.6069 | pavilion-kl |
| 10 | central-market-to-merdeka-118 | central-market | 3.1454, 101.6953 | merdeka-118 |
| 11 | little-india-brickfields-to-aquaria-klcc | little-india-brickfields | 3.1306, 101.6869 | aquaria-klcc |
| 12 | national-museum-to-pudu-raya | national-museum | 3.1378, 101.687 | pudu-raya |
| 13 | mid-valley-to-ioi-city-mall | mid-valley | 3.118, 101.6779 | ioi-city-mall |
| 14 | kl-sentral-to-sunway-pyramid | kl-sentral | 3.1343, 101.6869 | sunway-pyramid |
| 15 | berjaya-times-square-to-titiwangsa | berjaya-times-square | 3.1421, 101.7101 | titiwangsa |
Notes:
- The pair ids follow `<origin-id>-to-<dest-id>` (slug charset; the `?fixpair=` knob value in plan 05-05).
- `national-museum` appears ONLY as an ORIGIN (pair 12) — it is NEVER a destinationId (gate RT4 pins this; plan 05-05's pending capture uses `dest=national-museum` as the supported-but-out-of-corpus carrier).
- Distances span ~1 km (pair 10) to ~20 km (pair 13) — short/mid/long spread so the UI-SPEC's card/chips/camera behaviors are exercised across zoom ranges.
- Pairs 4/5 and 2/3 give direction-reversed coverage (one-way streets and alternatives differ by direction — the demo script walks these).
</pinned_corpus>
<osrm_contract>
The OSRM demo request shape (VERIFIED LIVE 2026-10-24 — see 05-RESEARCH.md Finding 2):
```
GET https://router.project-osrm.org/route/v1/driving/{originLng},{originLat};{destLng},{destLat}?alternatives=2&overview=full&geometries=polyline6
```
- `alternatives=2` → up to 2 alternates (≤ 3 total; matches RTNG-02's "up to 3").
- `overview=full` → complete geometry; `geometries=polyline6` → the string stored VERBATIM.
- Response: `code: "Ok"`, `routes[]` best-first with `distance` (m), `duration` (s), `geometry` (string).
- Per-pair HARD FAIL when `code !== "Ok"` or `routes.length === 0` — name the pair in the diagnostic, exit 1, write NOTHING (collect-then-write).
- Do NOT request `steps` (turn instructions are Phase 6; fixture steps stay empty — D-10).
- Do NOT retry on failure beyond ONE immediate retry per pair (transient 5xx); a second failure is a hard fail for that pair.
</osrm_contract>
<environment_facts>
Verified in this session — do not re-derive:
- The OSRM demo server answered a KL route request with `code: Ok`, 2 routes, polyline6 geometry (~1200 chars for ~6.9 km) on 2026-10-24 — the endpoint, parameter set, and response shape are current, not assumed.
- 15 pairs × 1 request each ≈ 15 requests + up to 1 retry each — light, fair use of a free demo server; the 1200 ms inter-request delay keeps it polite (~20 s total runtime).
- `search/scripts/overpass-topup.mjs` is the repo-tool precedent: mandatory descriptive User-Agent, inter-request delay, per-item soft diagnostics, writes committed JSON, imported by NOTHING at runtime.
- `search/scripts/gazetteer-corpus.mjs` exports `measureCorpus()` precisely so `search/test/corpus.test.mjs` can assert the same numbers in-process — "one measurement, two reporters, no drift" is the established corpus-gate shape.
- The gazetteer's 30 entries and exact coordinates are in `search/src/data/kl-gazetteer.json` — the corpus file's origin coordinates above are copied from there.
- The `search` workspace imports (`import … from "../src/index.ts"`) work in `.mjs` scripts under Node 24 type-stripping (`gazetteer-corpus.mjs` does exactly this).
</environment_facts>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| OSRM demo server → fetch script → committed dataset | A third-party free service supplies the geometry. Drift (data updates) is benign for a committed snapshot; POISONING (wrong/malformed geometry) must be caught offline by the tolerance gate before it can ship. |
| Pinned corpus file → fetch script | The hand-authored pairs file decides which coordinates leave the machine. A user coordinate or a private address entering this file would be shipped to a third party — the D-12 red line. |
| Committed dataset → bundle → users | Fixture bytes ride the web bundle (public data, but size is an availability budget — D-11). |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-05 | Tampering / Information Disclosure | (a) OSRM-supplied geometry inconsistent with its own summary silently shipping; (b) a non-public coordinate entering the pinned corpus and being sent to the demo server | mitigate | (a) RT5 recomputes each route's haversine polyline length OFFLINE and demands 0.85–1.10 × distanceM, RT6 bounds average speed to 1.0–30 m/s, RT3 re-decodes and re-encodes every geometry (exact round-trip) — a poisoned or drifting dataset fails `npm run routing:test`, not just the script. (b) RT2b mechanically proves every pinned origin equals SOME gazetteer entry coordinate within 1e-4 deg (≈11 m) — the corpus can only contain public landmark points, so the maintainer machine sends nothing private (D-12's "no user coordinate ever enters a fixture" made checkable). |
| T-05-12 | Denial of Service (availability budget) | Fixture payload growth pushing the web bundle over the D-11 budget | mitigate | RT9 gzips the committed dataset and fails above 100 KB (expected ≈ 20 KB at 15 pairs × ~1.2–2.5 KB) — early warning under BA9's 200 KB all-in ceiling (plan 05-05 owns the final gate; the two tiers share this threat ID); the gate prints the measured size so growth is visible, never discovered. |
| T-05-06 | Repudiation | Derivative OSM geometry shipped without licence/attribution | mitigate | The script writes the ODbL header fields (`_license`/`_attribution`/`_notice`/`_source`) into every regenerated file (they are part of the file template, not an optional add-on); RT8 asserts them post-write — the notice cannot be lost by a regeneration. |
</threat_model>
<tasks>
<task type="auto" tdd="true">
<name>task 1: pinned golden-pairs corpus + fetch script + populated committed dataset</name>
<files>package.json, routing/package.json, routing/test/fixtures/golden-pairs.json, routing/scripts/fetch-route-fixtures.mjs, routing/src/data/route-fixtures.json</files>
<read_first>
- `search/scripts/overpass-topup.mjs` (the repo-tool pattern: UA, delay, diagnostics, committed-JSON write)
- `search/src/data/kl-gazetteer.json` (the origin/destination coordinates and ids — the corpus file must agree with them)
- `routing/src/types.ts` (FixturePair/FixtureRoute — the written dataset must typecheck against these via the corpus runner's import)
- `routing/src/data/route-fixtures.json` (05-01's seed — this task REPLACES its `pairs: []` with real data, preserving the header fields)
- `.planning/phases/05-routing-route-display/05-RESEARCH.md` (Finding 2 — the verified request shape)
</read_first>
<action>
**`routing/test/fixtures/golden-pairs.json`** — hand-author the pinned corpus, exactly the `<pinned_corpus>` table:
```json
{
"_comment": "Pinned golden-pair corpus (D-02/D-12). Origins AND destinations are gazetteer landmarks (public data). RT2b proves every origin equals a gazetteer coordinate. national-museum is never a destinationId (RT4) — it is the 05-05 pending-probe carrier.",
"klBbox": { "minLat": 2.9, "maxLat": 3.4, "minLng": 101.4, "maxLng": 102.0 },
"fixtureGzipBudgetBytes": 102400,
"pendingProbeDestinationId": "national-museum",
"alternativesFloorPairs": 4,
"pairs": [
{ "pairId": "kl-tower-to-kl-sentral", "originId": "kl-tower", "origin": { "lat": 3.1528, "lng": 101.7038, "label": "Kuala Lumpur Tower" }, "destinationId": "kl-sentral" },
{ "pairId": "kl-sentral-to-suria-klcc", "originId": "kl-sentral", "origin": { "lat": 3.1343, "lng": 101.6869, "label": "KL Sentral" }, "destinationId": "suria-klcc" },
{ "pairId": "suria-klcc-to-bangsar", "originId": "suria-klcc", "origin": { "lat": 3.1578, "lng": 101.7117, "label": "Suria KLCC" }, "destinationId": "bangsar" },
{ "pairId": "mid-valley-to-bukit-bintang", "originId": "mid-valley", "origin": { "lat": 3.118, "lng": 101.6779, "label": "Mid Valley Megamall" }, "destinationId": "bukit-bintang" },
{ "pairId": "bukit-bintang-to-mid-valley", "originId": "bukit-bintang", "origin": { "lat": 3.1466, "lng": 101.7106, "label": "Bukit Bintang" }, "destinationId": "mid-valley" },
{ "pairId": "pwtc-to-sunway-velocity", "originId": "pwtc", "origin": { "lat": 3.1768, "lng": 101.698, "label": "PWTC" }, "destinationId": "sunway-velocity" },
{ "pairId": "masjid-jamek-to-lake-gardens", "originId": "masjid-jamek", "origin": { "lat": 3.1484, "lng": 101.6953, "label": "Masjid Jamek" }, "destinationId": "lake-gardens" },
{ "pairId": "titiwangsa-to-petaling-street", "originId": "titiwangsa", "origin": { "lat": 3.1714, "lng": 101.7016, "label": "Taman Tasik Titiwangsa" }, "destinationId": "petaling-street" },
{ "pairId": "sunway-pyramid-to-pavilion-kl", "originId": "sunway-pyramid", "origin": { "lat": 3.0726, "lng": 101.6069, "label": "Sunway Pyramid" }, "destinationId": "pavilion-kl" },
{ "pairId": "central-market-to-merdeka-118", "originId": "central-market", "origin": { "lat": 3.1454, "lng": 101.6953, "label": "Central Market" }, "destinationId": "merdeka-118" },
{ "pairId": "little-india-brickfields-to-aquaria-klcc", "originId": "little-india-brickfields", "origin": { "lat": 3.1306, "lng": 101.6869, "label": "Brickfields" }, "destinationId": "aquaria-klcc" },
{ "pairId": "national-museum-to-pudu-raya", "originId": "national-museum", "origin": { "lat": 3.1378, "lng": 101.687, "label": "National Museum" }, "destinationId": "pudu-raya" },
{ "pairId": "mid-valley-to-ioi-city-mall", "originId": "mid-valley", "origin": { "lat": 3.118, "lng": 101.6779, "label": "Mid Valley Megamall" }, "destinationId": "ioi-city-mall" },
{ "pairId": "kl-sentral-to-sunway-pyramid", "originId": "kl-sentral", "origin": { "lat": 3.1343, "lng": 101.6869, "label": "KL Sentral" }, "destinationId": "sunway-pyramid" },
{ "pairId": "berjaya-times-square-to-titiwangsa", "originId": "berjaya-times-square", "origin": { "lat": 3.1421, "lng": 101.7101, "label": "Berjaya Times Square" }, "destinationId": "titiwangsa" }
]
}
```
**`routing/scripts/fetch-route-fixtures.mjs`** — the maintainer build tool (overpass-topup.mjs pattern). Header comment MUST contain this D-12 boundary statement verbatim: `D-12 BOUNDARY: this script runs on the MAINTAINER'S machine against FIXED PUBLIC coordinates (gazetteer landmarks). No user coordinate can ever enter a fixture. The app itself gains NO runtime dependency on this server or any network.` Behavior:
- Read `routing/test/fixtures/golden-pairs.json` + the gazetteer (`../node_modules/@araharah/search/src/data/kl-gazetteer.json`? NO — resolve the workspace package by RELATIVE path `../../search/src/data/kl-gazetteer.json`, the `gazetteer-corpus.mjs` cross-workspace import precedent — verify the path exists at runtime and hard-fail with a diagnostic if not).
- For each pair: validate the origin coordinate matches the gazetteer's `originId` entry within 1e-4 deg AND the destinationId exists (refuse to even fetch a non-public or unknown coordinate — fail fast, name the pair).
- Fetch the `<osrm_contract>` URL with header `User-Agent: araharah-fixtures/1.0 (build-time fixture precompute; repo: araharah)`, `1200 ms` delay between pairs, ONE retry on network/5xx failure.
- Per-pair hard fail on `code !== "Ok"` or `routes.length === 0` (collect-then-write: on ANY pair failure, exit 1 with the failing pair named and the committed file UNTOUCHED).
- Map each OSRM route → `{ geometry: r.geometry, distanceM: r.distance, durationS: r.duration }` (order preserved, cap 3).
- Write `routing/src/data/route-fixtures.json` preserving 05-01's header fields (`_license`, `_attribution`, `_notice`, `_source`) with `version: 1`, `originToleranceM: 200`, and the populated `pairs` — 2-space JSON, trailing newline.
- Support `--only=<pairId>` to regenerate one pair (merging into the existing committed file — a single-pair repair must not churn the other 14).
**Run the script NOW** (this is the plan's build step): `node routing/scripts/fetch-route-fixtures.mjs` from the repo root. The committed `routing/src/data/route-fixtures.json` MUST end this task with 15 real pairs. If the OSRM demo server is unreachable at execution time: retry the fetch up to 3 times over ~10 minutes; if still unreachable, STOP this plan with an explicit CHECKPOINT note in the summary (fixtures are the phase's data product — shipping empty fixtures fails the corpus gate by design).
**Root `package.json`** — add `"routing:fixtures": "node routing/scripts/fetch-route-fixtures.mjs"` (delegate next to `routing:corpus`).
**`routing/package.json`** — add `"fixtures": "node scripts/fetch-route-fixtures.mjs"` and `"corpus": "node scripts/route-corpus.mjs"` scripts.
**Acceptance:** the committed dataset carries 15 pairs; `npm run routing:test` still exits 0 (05-01's suites pass against REAL data — F1's empty-seed case L3 now asserts the default source serves kl-tower→kl-sentraL… NOTE: loader-env L3 asserted the seed serves null; with real data it must now assert a KNOWN pair SERVES — update `L3` in `routing/test/loader-env.test.mjs` accordingly: the default source serves pair 1 for a request at its pinned origin, still returns null for an origin 300 m away).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && node -e "const f=require('./routing/src/data/route-fixtures.json'); if(f.pairs.length<10||f.pairs.length>20) throw new Error('pair count '+f.pairs.length); for(const p of f.pairs){if(p.routes.length<1||p.routes.length>3) throw new Error(p.pairId+' route count');} console.log('pairs:', f.pairs.length)" ; grep -q '"routing:fixtures"' package.json && echo "delegate OK"</automated>
</verify>
<acceptance_criteria>
- `node -e "const f=require('./routing/src/data/route-fixtures.json'); if(f.pairs.length!==15) throw new Error(1); for(const p of f.pairs){ if(!/^[a-z0-9-]{1,64}$/.test(p.pairId)) throw new Error(2); if(p.routes.length<1||p.routes.length>3) throw new Error(3);} console.log('OK')"` exits 0
- `grep -q 'D-12 BOUNDARY: this script runs on the MAINTAINER' routing/scripts/fetch-route-fixtures.mjs` exits 0
- `grep -q 'router.project-osrm.org' routing/scripts/fetch-route-fixtures.mjs` exits 0
- `grep -q 'User-Agent' routing/scripts/fetch-route-fixtures.mjs` exits 0 AND `grep -q '1200' routing/scripts/fetch-route-fixtures.mjs` exits 0 (mandatory UA + inter-request delay)
- `grep -q '"routing:fixtures": "node routing/scripts/fetch-route-fixtures.mjs"' package.json` exits 0
- `test -f routing/test/fixtures/golden-pairs.json` exits 0 AND `node -e "const c=require('./routing/test/fixtures/golden-pairs.json'); if(c.pairs.length!==15) throw new Error(1); if(c.pendingProbeDestinationId!=='national-museum') throw new Error(2); console.log('OK')"` exits 0
- `node -e "const f=require('./routing/src/data/route-fixtures.json'); const bad=f.pairs.filter(p=>p.destinationId==='national-museum'); if(bad.length) throw new Error('probe id used as destination'); console.log('RT4 OK')"` exits 0
</acceptance_criteria>
<done>The pinned 15-pair corpus exists, the maintainer fetch tool exists with the D-12 boundary documented, and the committed fixture dataset carries 15 real OSRM-demo-precomputed pairs (1–3 routes each, verbatim polyline6).</done>
</task>
<task type="auto" tdd="true">
<name>task 2: the offline corpus tolerance gate (CLI + in-suite Nyquist gate)</name>
<files>routing/scripts/route-corpus.mjs, routing/test/corpus.test.mjs</files>
<read_first>
- `search/scripts/gazetteer-corpus.mjs` (the "one measurement, two reporters" shape: exported measure core + CLI report + fixed escalation ladder on failure)
- `search/test/corpus.test.mjs` (the in-process assertion suite that imports the measurement core — NEVER spawns the script)
- `routing/src/index.ts` (the public API the measurement core imports: decodePolyline6, haversineM, getPairs)
- `routing/test/fixtures/golden-pairs.json` (task 1's thresholds: bbox, gzip budget, alternatives floor, probe id)
</read_first>
<action>
**`routing/scripts/route-corpus.mjs`** — the corpus measurement core + CLI reporter. Header contract (gazetteer-corpus.mjs style): a LOCAL DEV TOOL over committed files; the WORKER/WEBVIEW never prints coordinates or pair data (D-15: this script prints pair ids and measurements only — never geometry strings). Exports:
```typescript
export function measurePairs(corpus, fixtures, gazetteer): MeasurementReport;
```
`MeasurementReport` carries per-check counts + violations arrays. The measurement core performs RT1–RT10 (ALL OFFLINE — zero network in the measurement path):
- **RT1 corpus size** — `10 <= pairs.length <= 20`.
- **RT2 schema + public origin** — every pair: pairId unique + slug charset; origin label non-empty; origin inside `klBbox`; destinationId exists in the gazetteer; AND **RT2b**: the gazetteer entry whose `id === originId` satisfies `|entry.lat − origin.lat| <= 1e-4` AND `|entry.lng − origin.lng| <= 1e-4` — i.e. the pinned origin EQUALS the named gazetteer landmark's committed coordinate (the mechanical no-user-coordinate proof, D-12).
- **RT3 route shape** — every pair has 1..3 routes; every route: finite `distanceM > 0`, finite `durationS > 0`, non-empty `geometry` string.
- **RT4 probe exclusion** — NO pair's `destinationId` equals `pendingProbeDestinationId` (`national-museum`).
- **RT5 geometry↔summary consistency** — decode each geometry; `haversineLengthM(points)` (sum of segment distances) within `[0.85, 1.10] × distanceM`; decoded points all finite and inside the KL bbox (with 0.05 deg slack for OSRM's road snapping).
- **RT6 duration sanity** — `distanceM / durationS` within `[1.0, 30]` m/s (≈ 3.6–108 km/h — KL congestion floor to DUKE highway ceiling).
- **RT7 codec round-trip** — `encodePolyline6(decodePolyline6(geometry)) === geometry` for every route (the 1e-6-grid law, verbatim storage proof).
- **RT8 licence header** — `_license === "ODbL-1.0"`, `_attribution` contains `OpenStreetMap`, `_notice` contains `ODbL`, `_source` contains `router.project-osrm.org` (T-05-06).
- **RT9 size budget** — `gzipSync(readFileSync(fixturesPath)).length <= corpus.fixtureGzipBudgetBytes` (102400); the report PRINTS the measured bytes (T-05-11 early warning).
- **RT10 alternatives floor** — `pairs.filter(p => p.routes.length >= 2).length >= corpus.alternativesFloorPairs` (4) — RTNG-02's real-data requirement.
CLI: prints a per-check table (`RT1..RT10` with counts + violation details: pairId + reason — never geometry text), exits 0 ONLY when every check passes; on failure prints the fixed escalation ladder (inspect `routing/scripts/fetch-route-fixtures.mjs --only=<pairId>` for the failing pair; re-run the fetch; re-run the gate).
**`routing/test/corpus.test.mjs`** — RED-first-then-GREEN (the fixtures already exist from task 1, so the suite goes straight to green; a future regression re-reddens it). Import `measurePairs` from the script + the three JSON inputs via `readFileSync`/`import … with { type: "json" }`. One `test()` per check: `RT1`…`RT10`, each asserting `violations.length === 0` with a named message. Mirror the gazetteer corpus suite's in-process shape exactly (never spawns the CLI).
**Acceptance:** `npm run routing:corpus` exits 0 printing RT1–RT10 green with the measured gz size; `npm run routing:test` exits 0 including `PASS RT1`…`PASS RT10`.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run routing:corpus 2>&1 | tail -15 ; npm run routing:test 2>&1 | tail -15</automated>
</verify>
<acceptance_criteria>
- `npm run routing:corpus` exits 0 and its output contains `RT1` through `RT10` and a gz-size line
- `npm run routing:test` exits 0 and the output contains `PASS RT1` through `PASS RT10`
- `grep -q 'export function measurePairs' routing/scripts/route-corpus.mjs` exits 0 (the shared measurement core)
- `grep -q 'measurePairs' routing/test/corpus.test.mjs` exits 0 (in-process import — the two-reporters law)
- `node -e "const f=require('./routing/src/data/route-fixtures.json'); const multi=f.pairs.filter(p=>p.routes.length>=2).length; if(multi<4) throw new Error('alternatives floor: '+multi); console.log('RT10 OK:', multi)"` exits 0
- `node -e "const{gzipSync}=require('node:zlib');const{readFileSync}=require('node:fs');const s=gzipSync(readFileSync('./routing/src/data/route-fixtures.json')).length; if(s>102400) throw new Error('gz '+s); console.log('gz bytes:', s)"` exits 0
- `grep -c 'fetch(' routing/scripts/route-corpus.mjs | head -1` returns 0 (the measurement core is OFFLINE — T-05-05a)
</acceptance_criteria>
<done>The committed 15-pair dataset is pinned behind ten offline checks — corpus size, public-origin proof, schema, probe exclusion, geometry↔summary consistency, duration sanity, codec round-trip, licence header, gz size budget, and the alternatives floor — enforced identically by the CLI and the in-suite gate.</done>
</task>
</tasks>
<verification>
1. `npm run routing:corpus` — RT1–RT10 green, measured gz size printed (expect ≈ 15–25 KB, budget 100 KB).
2. `npm run routing:test` — 05-01's C/P/F/L suites PLUS RT1–RT10, all green (the loader-env L3 update: default source now SERVES pair 1 at its pinned origin and returns null 300 m away).
3. `npm run routing:typecheck` — exit 0.
4. The committed dataset: 15 pairs, ODbL header intact, `national-museum` never a destination.
5. Network isolation: `grep -rn "fetch(" routing/scripts/route-corpus.mjs routing/test/corpus.test.mjs` → no matches (the gate never touches the network; only the maintainer fetch script does).
</verification>
<success_criteria>
- 15 golden KL pairs committed with real OSRM-demo geometry, pinned origins proven public (RT2b), 1–3 best-first routes each.
- The offline tolerance gate holds in both reporters (CLI + suite) with zero network access.
- At least 4 pairs carry ≥ 2 routes (RTNG-02's alternatives floor).
- The fetch tool is a documented repo-tool with the D-12 boundary in its header.
</success_criteria>
@@ -0,0 +1,211 @@
---
phase: 05-routing-route-display
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- bot/src/kv/guidance-sessions.ts
- bot/test/guidance-envelope.test.mjs
autonomous: true
requirements: []
must_haves:
decisions_implemented:
- "D-06: the guidance_sessions envelope is a TYPES-ONLY frozen contract — code types + docs, NO KV namespace, NO service, NO runtime writes; Phase 6 builds the service on the frozen shape"
- "D-07: the route snapshot lives INLINE in the envelope record — one key, one TTL (~2h); nothing outlives 2h, nothing reconstructable after expiry"
truths:
- "GuidanceSessionEnvelope has EXACTLY four fields: id, chat_id, route_snapshot, created_at — a 5th property means drift (the SessionClaim 'entire persisted claim surface' law)"
- "route_snapshot is an INLINE interface (GuidanceRouteSnapshot), never a reference/id — grep-gated: no snapshotRef/snapshot_ref token exists in bot/src"
- "GUIDANCE_SESSION_TTL_SECONDS === 7200 — the ~2h TTL exists as a documented constant awaiting its Phase 6 consumer, pinned by test"
- "guidance-sessions.ts contains zero runtime behavior: no KvNamespace reference, no .put/.get/.delete, no exported function — types + one constant + the key-scheme doc only"
- "bot/wrangler.jsonc still carries EXACTLY ONE kv namespace (chat_sessions) and Env carries NO guidance field — the grep gates prove the types-only boundary mechanically"
artifacts:
- path: "bot/src/kv/guidance-sessions.ts"
provides: "the frozen Phase 6 contract: GuidanceSessionEnvelope + GuidanceRouteSnapshot types, GUIDANCE_SESSION_TTL_SECONDS = 7200, the guidance:<id> key-scheme doc header"
contains: "export interface GuidanceSessionEnvelope"
- path: "bot/test/guidance-envelope.test.mjs"
provides: "the GE1–GE7 types-only gates (field freeze, inline snapshot, TTL constant, no-runtime-behavior, no new namespace, no Env field, no premature consumer)"
contains: "GE1"
key_links:
- from: "bot/src/kv/guidance-sessions.ts"
to: "bot/src/kv/sessions.ts"
via: "the SessionClaim 'entire persisted claim surface — a 5th property means drift' law, applied to the envelope's field freeze"
pattern: "SessionClaim"
- from: "bot/test/guidance-envelope.test.mjs"
to: "bot/src/kv/guidance-sessions.ts"
via: "source-text + structural gates pin the types-only boundary until Phase 6's service replaces it"
pattern: "GE1"
---
<objective>
Freeze the Phase 6 guidance-session contract NOW as a types-only module: the `guidance_sessions` envelope (id, chat ref, inline route snapshot, created_at) with its ~2h TTL constant and `guidance:<id>` key-scheme documentation — no KV namespace, no service, no runtime writes.
Purpose: ROADMAP §Phase 5 notes bind the GUID-05/06 upstream design obligation here ("the ephemeral guidance_sessions KV envelope (id, chat ref, route snapshot ref, ~2h TTL) is designed here so the backgrounded turn-card service in Phase 6 has its session contract already defined"). The pivot (D-06) sharpens "route snapshot ref" into an INLINE snapshot (D-07 — one key, one TTL, nothing reconstructable after expiry) and pins the whole thing as types + docs. Phase 6 implements the service against this frozen shape without renegotiation.
Output: `bot/src/kv/guidance-sessions.ts` (types + TTL constant + key-scheme header) and `bot/test/guidance-envelope.test.mjs` (GE1–GE7 gates proving the types-only boundary mechanically).
</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
@bot/src/kv/sessions.ts
@bot/src/config.ts
@bot/wrangler.jsonc
@bot/test/privacy.test.mjs
@routing/src/types.ts
<interfaces>
The frozen contract (verbatim — Phase 6 consumes these exact names):
```typescript
/** ~2h (D-06) — the envelope's every future write carries THIS value as its
* expirationTtl (Phase 6's privacy grep gate extends the TTL-law to it). */
export const GUIDANCE_SESSION_TTL_SECONDS = 7200;
/**
* The route snapshot — INLINE in the envelope (D-07): one KV key, one TTL.
* Nothing outlives 2h; nothing is reconstructable after expiry. Scoped to
* what a chat turn-card needs (GUID-05): the selected route's headline data
* + its geometry for the map snapshot render + the trip-start anchor for
* progress math on resume (GUID-06).
*/
export interface GuidanceRouteSnapshot {
/** The gazetteer id of the trip's destination. */
destinationId: string;
/** The destination's display name (verbatim gazetteer name). */
destinationName: string;
/** Selected route summary (meters / seconds). */
distanceM: number;
durationS: number;
/** The SELECTED route's geometry, polyline6-encoded — the routing
* workspace's storage format (05-01), never a re-serialized array. */
geometry: string;
/** Trip start, seconds-since-epoch — the resume-progress anchor (GUID-06). */
startedAtSec: number;
}
/**
* The guidance_sessions envelope — Phase 6's frozen contract (D-06).
* EXACTLY these four fields: a 5th property means drift (the SessionClaim
* law, bot/src/kv/sessions.ts).
*
* Key scheme: guidance:<id> → JSON GuidanceSessionEnvelope
* Every write MUST carry expirationTtl: GUIDANCE_SESSION_TTL_SECONDS.
*/
export interface GuidanceSessionEnvelope {
/** Opaque session id — crypto.randomUUID() at Phase 6 mint (T-02-05 precedent). */
id: string;
/** The Telegram chat the turn-cards push to (GUID-05). */
chat_id: number;
/** The INLINE route snapshot (D-07) — never a reference or a second key. */
route_snapshot: GuidanceRouteSnapshot;
/** Envelope creation, seconds-since-epoch. */
created_at: number;
}
```
Module header contract (sessions.ts style — states the types-only law):
```typescript
/*
* guidance-sessions.ts — the FROZEN Phase 6 contract, types-only (D-06/D-07).
* ------------------------------------------------------------------------
* THIS MODULE SHIPS NO BEHAVIOR. No KVNamespace binding, no Env field, no
* wrangler namespace, no runtime function — the envelope is designed here
* (ROADMAP §Phase 5 GUID-05/06 upstream obligation) so Phase 6 builds its
* service on a frozen shape. Phase 6's service owns every write; every
* write carries expirationTtl: GUIDANCE_SESSION_TTL_SECONDS (~2h) and the
* snapshot is INLINE (one key, one TTL) — nothing outlives 2h, nothing is
* reconstructable after expiry (D-07, the location-pin TTL precedent).
*
* Key scheme: guidance:<id> → JSON GuidanceSessionEnvelope
*/
```
</interfaces>
<environment_facts>
Verified in this session — do not re-derive:
- `bot/src/kv/sessions.ts` establishes the "entire persisted claim surface — a 5th property means drift" field-freeze law (its `SessionClaim` doc) and the every-write-carries-TTL grep-gate pattern (`bot/test/privacy.test.mjs` PG1).
- `bot/wrangler.jsonc` `kv_namespaces` carries EXACTLY ONE entry (`chat_sessions`, id `59f58a54bc44483e89872b20a005a5eb`); `bot/src/config.ts` `Env` carries exactly `BOT_TOKEN`, `WEBHOOK_SECRET`, `chat_sessions`, `MINI_APP_URL`.
- `bot/test/runner.mjs` auto-discovers `*.test.mjs` — a new suite file is picked up with zero registration.
- The routing workspace's geometry storage format is polyline6 (05-01) — the snapshot's `geometry` field references that format by name in its doc comment.
</environment_facts>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| This module → the Worker runtime | A types-only module that accidentally grew behavior (a helper, a KV call) would create an unreviewed write path outside Phase 6's privacy gates — the threat is PREMATURE PERSISTENCE. |
| The frozen shape → Phase 6's service | Contract drift between what Phase 5 froze and what Phase 6 builds silently widens the persisted surface (a 5th field, a snapshot reference instead of an inline record). |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-08 | Tampering / Information Disclosure | Premature persistence — a half-built guidance write path landing before Phase 6's TTL/privacy gates exist | mitigate | GE4 pins the module to types + one constant (comment-stripped scan: zero `KvNamespace`, `.put(`, `.get(`, `.delete(`, `export function`, `export async function`); GE5 pins wrangler.jsonc to exactly one kv namespace; GE6 pins `Env` to its four existing fields; GE7 pins zero non-test imports of the module. The contract CANNOT acquire a runtime shadow this phase. |
| T-05-08b | Repudiation (retention) | The envelope silently outliving its ~2h promise (D-07) | mitigate | GE1 pins `GUIDANCE_SESSION_TTL_SECONDS === 7200`; GE3 pins the INLINE snapshot (no `snapshotRef`/`snapshot_ref`/`snapshot_ref:` token anywhere in bot/src) — one key, one TTL, nothing reconstructable after expiry; the Phase 6 service extends the existing privacy TTL grep gate to `guidance:` writes. |
| T-05-08c | Tampering | Contract drift before Phase 6 (field added/renamed, geometry format changed) | mitigate | GE2 pins the envelope field set EXACTLY (`["id","chat_id","route_snapshot","created_at"]`) and the snapshot field set EXACTLY (`["destinationId","destinationName","distanceM","durationS","geometry","startedAtSec"]`) — any drift reddenens the suite. |
</threat_model>
<tasks>
<task type="auto" tdd="true">
<name>task 1: the frozen envelope types + the types-only gate suite</name>
<files>bot/src/kv/guidance-sessions.ts, bot/test/guidance-envelope.test.mjs</files>
<read_first>
- `bot/src/kv/sessions.ts` (the field-freeze law + TTL-constant style + module header contract)
- `bot/src/config.ts` (the Env contract GE6 pins — the file being modified is NOT this one; read it to assert its shape)
- `bot/wrangler.jsonc` (the kv_namespaces array GE5 pins)
- `bot/test/privacy.test.mjs` (the comment-stripping grep-gate style GE4 copies)
- `routing/src/types.ts` (the polyline6 storage format the snapshot's geometry field cites)
</read_first>
<action>
**`bot/src/kv/guidance-sessions.ts`** — implement the `<interfaces>` block EXACTLY: the module header (types-only law, key scheme, D-06/D-07 citations), `GUIDANCE_SESSION_TTL_SECONDS = 7200`, `GuidanceRouteSnapshot` (6 fields), `GuidanceSessionEnvelope` (4 fields). NOTHING ELSE — no imports, no functions, no class, no default export.
**`bot/test/guidance-envelope.test.mjs`** — RED-first, lazy-import style. Cases:
- `GE1 TTL constant` — `(await import("../src/kv/guidance-sessions.ts")).GUIDANCE_SESSION_TTL_SECONDS === 7200` (D-06's ~2h).
- `GE2 field freeze` — compile-time shape check via a typed probe: import the module under Node type-stripping and assert `Object.keys()`-equivalent surface by reading the source text: the `GuidanceSessionEnvelope` interface block contains EXACTLY the four fields `id`, `chat_id`, `route_snapshot`, `created_at` (regex the interface body between `interface GuidanceSessionEnvelope {` and the closing `}`; field lines match `/^\s{2}\w+:/`); same for `GuidanceRouteSnapshot` → exactly `destinationId`, `destinationName`, `distanceM`, `durationS`, `geometry`, `startedAtSec`. A 5th property reddenens this gate (the SessionClaim law).
- `GE3 inline snapshot` — the source contains `route_snapshot: GuidanceRouteSnapshot` (typed inline, not `string`/`number` reference) AND bot/src contains ZERO occurrences of `snapshotRef` or `snapshot_ref` (D-07's one-key-one-TTL law).
- `GE4 types-only` — strip comments from `guidance-sessions.ts` (the privacy.test.mjs pattern) and assert ZERO of: `KvNamespace`, `.put(`, `.get(`, `.delete(`, `export function`, `export async function`, `import ` (T-05-08: the module ships NO behavior).
- `GE5 no new namespace` — parse `bot/wrangler.jsonc` (strip `//` line comments, JSON.parse) and assert `kv_namespaces.length === 1` with binding `chat_sessions`.
- `GE6 no Env field` — `bot/src/config.ts` source contains NO `guidance` token (the Env contract gains nothing this phase).
- `GE7 no premature consumer` — grep `bot/src` for `guidance-sessions` imports: ZERO matches (only the test imports it — Phase 6 builds the consumer).
**Acceptance:** `npm test -w bot` exits 0 with `PASS GE1`…`PASS GE7` (plus all existing bot suites still green); `npm run bot:typecheck` exits 0 (the new module must typecheck — it lives in the bot workspace's tsconfig include).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -12 ; npm run bot:typecheck 2>&1 | tail -4</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` exits 0 and the output contains `PASS GE1` through `PASS GE7`
- `npm run bot:typecheck` exits 0
- `grep -q 'export interface GuidanceSessionEnvelope' bot/src/kv/guidance-sessions.ts` exits 0 AND `grep -q 'export interface GuidanceRouteSnapshot' bot/src/kv/guidance-sessions.ts` exits 0
- `grep -q 'GUIDANCE_SESSION_TTL_SECONDS = 7200' bot/src/kv/guidance-sessions.ts` exits 0
- `grep -q 'guidance:<id>' bot/src/kv/guidance-sessions.ts` exits 0 (the key scheme is documented)
- `grep -rc 'KvNamespace' bot/src/kv/guidance-sessions.ts | head -1` returns 0 (no runtime binding anywhere in the file, comments included — the word never appears)
- `grep -q 'route_snapshot: GuidanceRouteSnapshot' bot/src/kv/guidance-sessions.ts` exits 0 (inline, D-07)
</acceptance_criteria>
<done>The guidance_sessions envelope exists as a frozen types-only contract with its ~2h TTL constant, inline route snapshot, key-scheme documentation, and seven mechanical gates proving nothing runtime-shaped leaked in.</done>
</task>
</tasks>
<verification>
1. `npm test -w bot` — all existing suites + GE1–GE7 green.
2. `npm run bot:typecheck` — exit 0 (the new module is inside the bot tsconfig's include).
3. `bot/wrangler.jsonc` unchanged (still exactly one kv namespace); `bot/src/config.ts` unchanged.
4. `grep -rn "guidance" bot/src --include="*.ts" -l` → only `bot/src/kv/guidance-sessions.ts`.
</verification>
<success_criteria>
- The Phase 6 contract is frozen in code: envelope + inline snapshot + TTL constant + key scheme.
- Seven gates prove the types-only boundary (no behavior, no namespace, no Env field, no consumer).
- Zero existing bot files modified.
</success_criteria>
@@ -0,0 +1,515 @@
---
phase: 05-routing-route-display
plan: 04
type: execute
wave: 2
depends_on: ["01"]
files_modified:
- web/package.json
- web/src/i18n/strings.ts
- web/src/route/route-layers.ts
- web/src/route/route-markers.ts
- web/src/route/route-card.ts
- web/src/route/route.ts
- web/src/map/map.ts
- web/src/main.ts
- web/src/telegram/backbutton.ts
- web/src/shell.css
- web/test/copy-gate.test.mjs
- web/test/route-surface.test.mjs
autonomous: true
requirements: [RTNG-01, RTNG-02]
must_haves:
decisions_implemented:
- "D-09: route line = signal-orange casing stack (matte-black 9px under signal-orange 5px selected; matte-black 5px under soft-beige 3px unselected), opacity 1.0, line-cap square + line-join bevel, added once inside the existing load handler AFTER building-3d — the style is never re-set"
- "D-08: alternatives present as a bottom card + chips — one card for the selected route, up to 2 alternate chips, tap → instant geometry swap + 400ms camera refit; single-route draws render NO chip row"
- "D-03: the router-pending state is a designed Rams surface (center card, standard ink, NO red anywhere) — never a blank map, never a fake route; a route-source exception renders the same pending card (fail-honest)"
- "D-10: display-only terminal — NO start-guidance button, NO turn instructions, NO progress UI; the only affordance is .btn-secondary SEARCH AGAIN (shared with the BackButton walk-back)"
- "D-05/D-12: the route surface consults the fixture RoutingSource in the webview only — origin stays in memory, no route persists, the capture signal is a state token"
truths:
- "The four route layers are added exactly once, inside map.on('load'), after the building-3d block, with no beforeId, in bottom→top order route-alt-casing, route-alt-line, route-casing, route-line — zero setStyle calls exist in web/src/route/"
- "MapLibre paint consumes verbatim hex constants (#1A1A1A, #EB8008, #D9D2C6 copied from tokens.css) — CSS variables cannot feed paint (DSGN-01 note); the off-token sweep pattern covers them"
- "Camera fitBounds carries padding {top:96,bottom:320,left:64,right:64}, duration 400ms, pitch 35 (the boot constant, passed explicitly), bearing from getBearing() — center+zoom only"
- "Route consult happens automatically, exactly once per boot, only when the location chain is live AND a destination resolved — destination-unavailable and no-destination boots never make a route request"
- "Staleness never evicts the route card: stale during route state keeps the route card (strip lamp + dimmed marker carry the signal); stale during pending coexists the pending card (center) with the signal-lost card (Phase 3 bottom register)"
- "The BackButton is visible exactly when a pushed state exists (route or pending); its click walks to the root via the SAME total function SEARCH AGAIN invokes — never a no-op, never close() from a pushed state"
- "Every route string comes from the strings module (COPY5); the only console lines in web/src/route/ are the two ARAHARAH-ROUTE state tokens — never coordinates"
- "Card padding composes from the existing .card child rules and the new token-only route classes — zero per-card padding overrides (UI-SPEC post-approval addendum)"
artifacts:
- path: "web/src/route/route-layers.ts"
provides: "installRouteLayers (2 empty GeoJSON sources + 4 line layers), setRouteDrawn (setData swap), clearRouteLayers — MapLike-parameterized, Node-testable"
contains: "route-alt-casing"
- path: "web/src/route/route-markers.ts"
provides: "origin ring (24px, 2px matte-black stroke) + destination pin (12px square + 3px tail, name chip) as DOM/SVG markers, aria-hidden glyphs"
contains: "route-origin-ring"
- path: "web/src/route/route-card.ts"
provides: "buildRouteCard (register, headline, telemetry, difference, chips, SEARCH AGAIN) + buildRoutePendingCard — createElement + textContent only, strings-module copy"
contains: "buildRoutePendingCard"
- path: "web/src/route/route.ts"
provides: "createRouteController — consult/select/walkBack state machine (idle|route|pending), camera constants, ARAHARAH-ROUTE state signals"
contains: "ROUTE_CAMERA_PADDING"
- path: "web/src/shell.css"
provides: ".route-stack register rules, .route-telemetry/.route-chips/.route-chip, marker chrome — token references + the machined literals the UI-SPEC enumerates"
contains: "route-chip"
- path: "web/test/route-surface.test.mjs"
provides: "RS1–RS10 structural + fake-map behavioral gates (layer law, camera law, state machine, console discipline, D-10 absence laws)"
contains: "RS1"
key_links:
- from: "web/src/route/route.ts"
to: "@araharah/routing/src/index.ts"
via: "the controller consumes createFixtureRoutingSource() (backend #1) through the RoutingSource seam"
pattern: "createFixtureRoutingSource"
- from: "web/src/map/map.ts"
to: "web/src/route/route-layers.ts"
via: "the load handler's new opts.onLoad hook installs the route layers after building-3d, before the RAMS-BASEMAP-READY signal"
pattern: "onLoad"
- from: "web/src/main.ts"
to: "web/src/route/route.ts"
via: "boot wiring: auto-consult after live+destination; walkBack shared by BackButton and SEARCH AGAIN"
pattern: "consultRoute"
- from: "web/src/telegram/backbutton.ts"
to: "web/src/route/route.ts"
via: "overlayPresent becomes the route-state predicate; the pushed-state click invokes the injected walkBack (the SEARCH AGAIN total function)"
pattern: "walkBack"
- from: "web/src/route/route-card.ts"
to: "web/src/i18n/strings.ts"
via: "every route string is a ShellStrings key (COPY5) — the 10 new route keys"
pattern: "routePendingHeadline"
---
<objective>
Render the route experience on the live Phase 3 shell per the approved 05-UI-SPEC: orange casing route layers over the grayscale basemap, origin ring + destination pin, the bottom route card with alternates chips, the designed router-pending card, fit-bounds camera behavior, and the first real BackButton two-state walk — the visible half of RTNG-01/RTNG-02.
Purpose: the route is THE accent on a grayscale world (D-09); the card is its semantic statement (D-08); the pending state is honesty as a designed surface (D-03); and the terminal state is display-only (D-10 — no guidance furniture). Every geometry, color, string, and behavior is already BINDING in 05-UI-SPEC.md — this plan operationalizes it exactly, extending the shipped shell without restyling any existing surface.
Output: the `web/src/route/` module family (layers, markers, cards, controller), the map.ts load-hook extension, main.ts boot wiring, the two-state BackButton wire, shell.css route chrome, the 10 new string keys with the copy-gate extension, and the RS1–RS10 structural suite.
</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-UI-SPEC.md
@.planning/phases/05-routing-route-display/05-PATTERNS.md
@.planning/phases/05-routing-route-display/05-01-PLAN.md
@web/src/main.ts
@web/src/map/map.ts
@web/src/map/follow.ts
@web/src/map/marker.ts
@web/src/ui/cards.ts
@web/src/search/destination-card.ts
@web/src/search/resolve-destination.ts
@web/src/telegram/backbutton.ts
@web/src/i18n/strings.ts
@web/src/shell.css
@web/src/vite-env.d.ts
@web/test/copy-gate.test.mjs
@web/test/destination-preload.test.mjs
@routing/src/index.ts
<ui_spec_binding>
05-UI-SPEC.md is APPROVED and BINDING. The executor MUST read it in full before task 1. Non-negotiables restated for the tasks below (the spec wins on any disagreement — where spec, mockup, and tokens.css disagree: tokens.css wins, the mockup loses, the spec records the call):
- **Layer paint table** (spec §Color): route-casing = matte-black `#1A1A1A` 9px; route-line = signal-orange `#EB8008` 5px; route-alt-casing = `#1A1A1A` 5px; route-alt-line = soft-beige `#D9D2C6` 3px. ALL opacity 1.0. line-cap `square`, line-join `bevel`. Solid centerline (the mockup's dash is scrubbed residue).
- **Layer order** (spec §Color bullets): added inside the EXISTING `map.on("load")` handler, AFTER the building-3d block, each `addLayer` with NO beforeId → bottom→top: `route-alt-casing`, `route-alt-line`, `route-casing`, `route-line`. Sources `route` + `route-alt` added EMPTY at load, filled/cleared via `setData` (data, not style).
- **Camera table** (spec §Camera Behavior): route drawn → fitBounds padding `{ top: 96, bottom: 320, left: 64, right: 64 }`, duration 400ms; chip select → ALWAYS refits; SEARCH AGAIN → clear + flyTo latest fix via the follow controller; pitch untouched (35), bearing as the user left it.
- **Route card** (spec §Route Card): register `ROUTE` / `ROUTE {n}/{N}`; headline = destination name verbatim; telemetry row (telemetry-num time + `MIN` + distance `8.4 KM`); difference line (body-sm); chips row role=group, ≤ 2 chips, gap space-xs, never wraps; actions = ONE `.btn-secondary` SEARCH AGAIN. Height budget ≤ 240px (content contract). Single-route draws render NO chip row at all.
- **Chip contract** (spec): `<button type="button">`; matte-black plate, pure-white label-sm uppercase, padding 4/8, visible height 28px, zero radius; text `{nn} // {min} MIN · {km} KM` (e.g. `02 // 24 MIN · 9.1 KM`); hit ≥ 44×44 via `::after { inset: -8px }`; `:active` instant invert to signal-orange field + matte-black text; `:focus-visible` 3px orange bracket; NO persistent selected-chip state (chip count invariant N−1); aria-label from `ariaRouteChip` template; group carries `ariaRouteAlternatives`.
- **Origin ring + pin** (spec §Origin Ring + Destination Pin): ring = 24px diameter, 2px matte-black stroke, NO fill, at the geometry's first vertex, decorative `aria-hidden`; pin = 12×12 matte-black filled square + 3px triangular tail pointing down, 1px pure-white inset rule, NEVER orange; name chip above, offset space-sm, pure-white label-sm uppercase, verbatim name, max-width 160px ellipsis; route line terminates UNDER the pin, origin line starts INSIDE the ring.
- **Pending card** (spec §Router-Pending Card): center register; register `ROUTE // ROUTER`, headline, body, hint, CTA `.btn-secondary` SEARCH AGAIN — copy verbatim from the spec's en/ms table (now the strings keys); NO red anywhere (it is a deferral, not an error).
- **State coexistence** (spec §State Coexistence): route consult is AUTOMATIC (no route button) once location chain is live AND destination resolved; destination-unavailable → NO route request ever; no destination → Phase 3 behavior byte-identical; stale during route → route card persists, NO signal-lost card (strip lamp + dimmed marker carry it); stale during pending → pending card (center) + signal-lost card (bottom) coexist.
- **BackButton** (spec §BackButton Contract): route state → `show()`, click clears layers + ring + pin, card swaps to destination card, camera refollows user, `hide()`; pending state → `show()`, click swaps card to destination card, `hide()` (no layers to clear); destination card → hidden; the predicate becomes "route or pending state active"; handler and predicate ship as ONE change.
- **Micro-behaviors** (spec): no CSS transition/animation anywhere; instant hard swaps; route draw = setData + fitBounds same tick; a drawn route is static, only selection changes it.
- **Padding addendum** (spec, post-approval): the executor MUST NOT add per-card padding overrides — compose from the existing `.card > *` rules (shell.css:74-80) and the new token-only route classes.
- **Copy gate** (spec §Copywriting): the strings-module test asserts exactly these 10 keys in en + ms; no route copy inline in `web/src/route/*` or shell.css.
</ui_spec_binding>
<route_strings>
The 10 new `ShellStrings` keys (interface + both locales, en/ms verbatim from the UI-SPEC copy tables):
| Key | en | ms |
|-----|----|----|
| `routeRegister` | `ROUTE` | `ROUTE` |
| `routeDiffFastest` | `Fastest route` | `Laluan terpantas` |
| `routeDiffSlower` | `+{min} MIN VS ROUTE {n}` | `+{min} MIN VS LALUAN {n}` |
| `routePendingRegister` | `ROUTE // ROUTER` | `ROUTE // ROUTER` |
| `routePendingHeadline` | `Routing not wired up yet` | `Penghalaan belum dipasang` |
| `routePendingBody` | `Routes currently cover known KL landmarks only. The general router ships later — your coordinates were never sent anywhere.` | `Laluan buat masa ini meliputi mercu tanda KL yang diketahui sahaja. Penghala am akan datang kemudian — koordinat anda tidak pernah dihantar ke mana-mana.` |
| `routePendingHint` | `Send /cari in the chat and pick another landmark.` | `Hantar /cari dalam chat dan pilih mercu tanda lain.` |
| `ctaSearchAgain` | `SEARCH AGAIN` | `CARI SEMULA` |
| `ariaRouteAlternatives` | `Alternative routes` | `Laluan alternatif` |
| `ariaRouteChip` | `Route {n}: {min} minutes, {km} kilometres` | `Laluan {n}: {min} minit, {km} kilometer` |
`routeDiffSlower` and `ariaRouteChip` are TEMPLATES — the `{min}`/`{n}`/`{km}` placeholders are replaced in code (the Phase 3 telemetry composition precedent — formats are instrument constants, not locale strings). Compose formats in code: time `{n} MIN` (integer minutes = `Math.round(durationS / 60)`), distance `{n.n} KM` (one decimal = `(distanceM / 1000).toFixed(1)`), chip `{nn} // {min} MIN · {km} KM` (`nn` = zero-padded 2-digit option index), register `ROUTE {n}/{N}`.
</route_strings>
<module_contracts>
**`route-layers.ts`** — Node-testable (no DOM). Parameterize on a structural `MapLike` (METHOD syntax for parameter bivariance) so tests drive a recording fake and `MapLibreMap` satisfies it at the main.ts/map.ts call sites (bridge friction, if any, is a typed alias at the call site — never `any`; the `map.ts:32` typed-cast precedent):
```typescript
export interface MapLike {
addSource(id: string, data: unknown): void;
addLayer(layer: unknown): void;
getSource(id: string): { setData(data: unknown): void } | undefined;
fitBounds(bounds: unknown, options?: unknown): void;
getBearing(): number;
}
export const ROUTE_PAINT = {
casingColor: "#1A1A1A", // matte-black — verbatim from tokens.css (DSGN-01: zero invented colors)
lineColor: "#EB8008", // signal-orange
altLineColor: "#D9D2C6", // soft-beige
} as const;
export const ROUTE_LAYER_IDS = ["route-alt-casing", "route-alt-line", "route-casing", "route-line"] as const;
export function installRouteLayers(map: MapLike): void;
// no-op if map.getSource("route") already exists (idempotent);
// adds sources "route" + "route-alt" as EMPTY GeoJSON FeatureCollections,
// then the 4 layers IN ORDER with no beforeId.
export function setRouteDrawn(map: MapLike, selected: { points: RoutePoint[] }, alts: readonly { points: RoutePoint[] }[]): void;
// setData on BOTH sources: "route" ← selected LineString FeatureCollection,
// "route-alt" ← one Feature per alternate (0..2 features).
export function clearRouteLayers(map: MapLike): void;
// both sources ← empty FeatureCollection.
```
**`route-markers.ts`** — DOM/SVG map furniture (structural gates only; the capture legs prove the pixels). Exports:
```typescript
export function attachRouteEndpoints(map: MapLike2, firstPoint: RoutePoint, lastPoint: RoutePoint, destinationName: string): void;
// origin ring (SVG circle 24px, 2px matte-black stroke, no fill, aria-hidden) at firstPoint;
// destination pin (SVG 12×12 square + 3px tail, matte-black fill, 1px pure-white inset rule)
// + name chip (DOM, pure-white label-sm uppercase, max-width 160px, ellipsis) at lastPoint;
// maplibregl Marker with { element }, anchors: ring "center", pin group "bottom".
export function removeRouteEndpoints(map: MapLike2): void;
```
(`MapLike2` = the minimal marker surface: `{ addMarker/marker handling via maplibregl Marker .addTo/.remove }` — shape it as `{ addControl`-free: the module imports `Marker` from "maplibre-gl" directly and takes the map as `Parameters<Marker["addTo"]>[0]`.)
**`route-card.ts`** — createElement + textContent ONLY (an id/name-derived string NEVER reaches innerHTML — T-04-32 precedent). Composes theme classes + the new route classes; NO per-card padding overrides. Exports:
```typescript
export interface RouteCardActions {
onSelect(index: number): void; // chip tap → controller
onSearchAgain(): void; // SEARCH AGAIN → controller walkBack
}
export function buildRouteCard(
locale: Locale,
destinationName: string,
options: readonly { summary: RouteSummary }[],
selectedIndex: number,
actions: RouteCardActions,
): HTMLElement;
// .card: .card-header>.card-ref = "ROUTE" (no alts) | "ROUTE {i+1}/{N}";
// h2.text-headline-md = destinationName (verbatim);
// .route-telemetry: span.text-telemetry-num (minutes) + span.text-label-sm "MIN"
// + span.text-label-sm "{km} KM";
// p.text-body-sm difference line (routeDiffFastest when selected===0, else the
// routeDiffSlower template with {min} = selected − best minutes, {n} = 1);
// .route-chips[role="group"][aria-label] — ONE chip per NON-selected option
// (button.route-chip, ::after slop in CSS, aria-label from ariaRouteChip
// template, text "{nn} // {min} MIN · {km} KM"); ABSENT when options.length===1;
// .card-actions with ONE button.btn-secondary = ctaSearchAgain.
export function buildRoutePendingCard(locale: Locale, actions: { onSearchAgain(): void }): HTMLElement;
// .card: register routePendingRegister, h2 routePendingHeadline, p.text-body-md
// routePendingBody, p.text-body-sm routePendingHint, .card-actions with ONE
// .btn-secondary ctaSearchAgain. NO .btn-primary anywhere. NO red.
```
**`route.ts`** — the controller (all DOM/map concerns injected → Node-testable):
```typescript
export const ROUTE_CAMERA_PADDING = { top: 96, bottom: 320, left: 64, right: 64 };
export const ROUTE_FIT_DURATION_MS = 400;
export const ROUTE_CAMERA_PITCH = 35; // the boot constant (map.ts), passed explicitly
export type RouteState = "idle" | "route" | "pending";
export function createRouteController(deps: {
map: MapLike;
source: RoutingSource; // @araharah/routing fixture backend
buildRouteCard: typeof buildRouteCard; // injected (DOM-free tests)
buildPendingCard: typeof buildRoutePendingCard;
showRouteCard: (card: HTMLElement | null, register: "center" | "bottom") => void;
showDestinationCard: () => void; // main.ts: Phase 3 stack shows the destination card
follow: { follow(fix: Fix): void }; // the existing follow controller
getLatestFix: () => Fix | null;
onStateChange: (state: RouteState) => void; // main.ts: overlay predicate + BackButton show/hide
}): {
readonly state: RouteState;
consult(fix: Fix, destination: GazetteerEntry): Promise<void>;
walkBack(): void;
};
// consult(): ONE call per boot (main.ts guards with a consulted flag):
// result = await source.route({ origin: {lat,lng}, destinationId }).catch(() => null);
// null/empty → pending: buildPendingCard → showRouteCard(card, "center");
// state = "pending"; console.log("ARAHARAH-ROUTE state=pending"); (fitBounds NOT called)
// else → drawn: setRouteDrawn(map, options[sel], others); attachRouteEndpoints(...);
// buildRouteCard → showRouteCard(card, "bottom");
// fitBounds(geometry bounds, { padding: ROUTE_CAMERA_PADDING, duration:
// ROUTE_FIT_DURATION_MS, pitch: ROUTE_CAMERA_PITCH, bearing: map.getBearing() });
// state = "route"; console.log("ARAHARAH-ROUTE state=drawn");
// onSelect(i): setRouteDrawn with the swapped arrays; re-build the card (new
// register/telemetry/difference/chips); refit the NEW geometry's bounds (ALWAYS
// refits — D-10). Instant swaps, no transitions.
// walkBack(): route → clearRouteLayers + removeRouteEndpoints + showDestinationCard()
// + follow.follow(getLatestFix()) when a fix exists; pending → showDestinationCard()
// only. Both: state = "idle", onStateChange fires.
```
The ONLY console lines permitted in `web/src/route/` are the two `ARAHARAH-ROUTE state=` tokens (RS8 gates this; D-15 hygiene — a state token, never coordinates).
</module_contracts>
<environment_facts>
Verified in this session — do not re-derive:
- `map.ts` lines 43–67 hold the ONLY `map.on("load")` handler: building-3d try/catch, then `console.log("RAMS-BASEMAP-READY layers=" + …)`. The route install slots BETWEEN them (before the log — the capture race implies route sources exist).
- `main.ts` line 64: `const overlayPresent = (): boolean => false;` — the Phase 3 single-state predicate this plan replaces; `wireBackButton(wa, overlayPresent)` is called at line 77, BEFORE the map boots — the route predicate must therefore be an assignable closure (`let routeStateActive = () => false;` pattern), not a direct reference.
- `main.ts` `showCard(card, register)` manages ONE `.card-stack` (center via top 50%/translate, bottom via safe-bottom + space-xl). The route state gets its OWN stack element (`.route-stack`, data-register driven) — Phase 3's stack logic stays byte-identical (the "No destination → Phase 3 behavior byte-identical" law) and the pending+signal-lost coexistence (different registers) falls out of two independent stacks.
- `follow.ts` exports `CAMERA_PADDING_PX = 96` and `RECENTER_DURATION_MS = 400` — the route's constants sit BESIDE them in `route.ts` (the UI-SPEC: "the route adds its own padded constants beside them").
- `destination-card.ts` is the card-builder style precedent (createElement + textContent ONLY, theme classes, strings-module copy).
- `copy-gate.test.mjs` COPY1 asserts `enKeys.length >= 25` (31 keys today — new floor 40 after +10) and COPY5 scans `UI_DIR = src/ui` only — COPY5's scope gains `src/route` in task 1.
- `@araharah/routing` exports `createFixtureRoutingSource`, `RoutingSource`, `RouteResult`, `RouteOption` (plan 05-01) — `web/package.json` already depends on `@araharah/search`; ADD `"@araharah/routing": "*"` to `web/package.json` dependencies (task 1) and import via the explicit subpath `@araharah/routing/src/index.ts`.
- The webview bundle inlines the routing workspace's static JSON import the same way it inlines the gazetteer (esbuild/Vite static-import inlining — proven by search; the 05-05 BA gates assert it).
- Node 24 `.mjs` tests CAN import the web `.ts` modules (type-stripping) — but NOT modules touching `document` (no DOM in Node): the controller takes card builders + show callbacks as injected deps precisely so RS-gates run in Node.
</environment_facts>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Gazetteer destination name / pair labels → DOM | Name-derived strings flow into the route card, the pin's name chip, and aria-labels. An innerHTML sink would convert data into markup injection. |
| Route geometry → MapLibre sources | Decoded fixture coordinates drive `setData`. Malformed geometry (plan 05-01's decoder throws; the backend skips) must never reach a `LngLat` — a NaN would pageerror the map thread. |
| Route state → BackButton graph | A pushed state with a dead-end back handler strands the user inside the webview (the no-dead-ends law, PX-01 groundwork). |
| Console surface → capture signals | The console is a capture contract AND a leak surface: coordinates in a log line violate the D-15 hygiene law. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-09 | Tampering (rendering integrity) | A style re-creation or layer re-add clobbering the Rams basemap (the D-10 map law: additions only) | mitigate | Layers install ONCE inside the load handler (idempotence guard on `getSource("route")`); selection swaps are `setData` on existing sources; RS2 asserts ZERO `setStyle` tokens in `web/src/route/` and the install's position (after building-3d, before the ready log). |
| T-05-10 | Tampering (injection) | Destination name or pair label reaching `innerHTML` | mitigate | Both card builders use createElement + textContent ONLY (the destination-card.ts law); RS-greps the new modules for `innerHTML`/`insertAdjacentHTML` — zero matches. |
| T-05-11 | Denial of Service (UX dead end) | BackButton visible but dead (or closing the app from a pushed state) | mitigate | The predicate + walkBack ship as ONE change (UI-SPEC): RS7 proves click-with-overlay invokes the injected walkBack and NEVER `close()`, and click-without-overlay closes; the walkBack is the SAME total function SEARCH AGAIN binds. |
| T-05-13 | Information Disclosure | Route consult leaking coordinates into console lines | mitigate | The ONLY console tokens in `web/src/route/` are the two `ARAHARAH-ROUTE state=` lines (RS8: comment-stripped scan finds no other `console.` AND no `${` interpolation inside a console call). The origin never persists — the controller holds it in its closure for the session only (D-07). |
| T-05-04 | Spoofing | A route-source exception silently drawing a partial route | mitigate | `source.route()` failures resolve to the PENDING card (`.catch(() => null)` — fail-honest, never a half-drawn line, the UI-SPEC error-state law); RS4 proves the exception path renders pending and calls NO fitBounds. |
</threat_model>
<tasks>
<task type="auto" tdd="true">
<name>task 1: route strings + copy-gate extension + workspace dependency</name>
<files>web/src/i18n/strings.ts, web/test/copy-gate.test.mjs, web/package.json</files>
<read_first>
- `web/src/i18n/strings.ts` (the ShellStrings interface + both locale tables — the file this task extends)
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Copywriting Contract (the binding en/ms copy table)
- `web/test/copy-gate.test.mjs` (COPY1 floor + COPY5 scope — extended here)
- `web/package.json` (dependencies — gains `@araharah/routing`)
</read_first>
<action>
**`web/package.json`** — add `"@araharah/routing": "*"` to `dependencies` ( beside `"@araharah/search": "*"`), then `npm install --package-lock-only --workspaces` to settle the lockfile.
**`web/src/i18n/strings.ts`** — add the 10 keys from the `<route_strings>` block: extend `ShellStrings` (after `homeScreenAlreadyAdded`) and BOTH locale objects with the verbatim en/ms values. The two template keys carry their `{min}`/`{n}`/`{km}` placeholders LITERALLY in the string values (replaced in code). Update the module header's key inventory note (it names the register labels).
**`web/test/copy-gate.test.mjs`** — three targeted extensions (RED first — they fail against the un-extended strings.ts):
- COPY1: floor `>= 25` → `>= 40`; add assertions that `routeRegister`, `routePendingRegister`, `ctaSearchAgain` are keys present in BOTH locales.
- New `COPY7 route copy verbatim`: assert `strings.en.routePendingHeadline === "Routing not wired up yet"`, `strings.en.routePendingBody` contains `never sent anywhere`, `strings.en.ctaSearchAgain === "SEARCH AGAIN"`, `strings.ms.routePendingHeadline === "Penghalaan belum dipasang"`, `strings.ms.ctaSearchAgain === "CARI SEMULA"`, `strings.en.routeDiffSlower === "+{min} MIN VS ROUTE {n}"`, `strings.ms.routeDiffSlower === "+{min} MIN VS LALUAN {n}"`, `strings.en.ariaRouteChip === "Route {n}: {min} minutes, {km} kilometres"`, and `strings.en.routePendingRegister === strings.ms.routePendingRegister === "ROUTE // ROUTER"` (instrument vocabulary, identical in both locales).
- COPY5 scope: extend the module-scan list with `src/route` (the gate's directory constant gains the route module family — keep the existing assertion shape; it asserts no inline copy in ANY scanned shell module).
**Acceptance:** `npm test -w web` exits 0 with COPY1/COPY5/COPY7 green (plus all existing web suites); `npm run web:typecheck` exits 0.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -15 ; npm run web:typecheck 2>&1 | tail -4 ; grep -c '"@araharah/routing"' web/package.json</automated>
</verify>
<acceptance_criteria>
- `grep -q '"@araharah/routing": "\*"' web/package.json` exits 0
- `npm test -w web` exits 0 and the output contains `PASS COPY7`
- `grep -q 'routePendingHeadline' web/src/i18n/strings.ts` exits 0 AND `grep -q 'Penghalaan belum dipasang' web/src/i18n/strings.ts` exits 0 AND `grep -q 'CARI SEMULA' web/src/i18n/strings.ts` exits 0
- `grep -q 'route' web/test/copy-gate.test.mjs` exits 0 (the COPY7 case + scope extension landed)
- `npm run web:typecheck` exits 0
</acceptance_criteria>
<done>The 10 route string keys exist verbatim in both locales, the copy gate pins them (floor 40, COPY7 verbatim case, COPY5 covers src/route), and the web workspace resolves @araharah/routing.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: route layers + endpoint markers + shell.css chrome + map.ts load hook</name>
<files>web/src/route/route-layers.ts, web/src/route/route-markers.ts, web/src/map/map.ts, web/src/shell.css</files>
<read_first>
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Color (paint table) + §Origin Ring + Destination Pin + §Shell Layout (the 64px composite)
- `web/src/map/map.ts` (the load handler + building-3d block — the insertion point)
- `web/src/map/marker.ts` (the DOM/SVG marker + shell.css chrome pattern the ring/pin copy)
- `web/src/shell.css` (token-only law, machined-literal exception list, the existing `.card > *` padding rules the route classes must NOT override)
- `routing/src/index.ts` (RoutePoint — the geometry shape the sources carry)
</read_first>
<action>
**`web/src/route/route-layers.ts`** — per the `<module_contracts>` block: `MapLike` (method syntax), `ROUTE_PAINT` (three verbatim hex constants with the DSGN-01 comment), `ROUTE_LAYER_IDS`, `installRouteLayers` (idempotent; 2 empty sources + 4 layers, no beforeId, ORDER: route-alt-casing → route-alt-line → route-casing → route-line), `setRouteDrawn`, `clearRouteLayers`. Every layer object: `type: "line"`, `layout: { "line-cap": "square", "line-join": "bevel" }`, `paint: { "line-color": <constant>, "line-width": <9|5|5|3>, "line-opacity": 1 }`. Header comment: the D-09 contract + the DSGN-01 verbatim-hex note + "the style is never re-set — sources are data".
**`web/src/route/route-markers.ts`** — `attachRouteEndpoints` / `removeRouteEndpoints` per `<module_contracts>`: SVG ring (24px circle, 2px matte-black stroke, fill none, `aria-hidden="true"`) and SVG pin (12×12 filled square + 3px triangular tail, matte-black, 1px pure-white inset rule via an inner rect stroke, `aria-hidden="true"`) + DOM name chip (label-sm uppercase, pure-white on matte-black plate, max-width 160px, ellipsis, verbatim name). Use `Marker` from `maplibre-gl` with `{ element }`, ring anchored center at the geometry's FIRST point, pin group anchored bottom at the LAST point. Track attached markers in a module-scoped `WeakMap<MapLike2, Marker[]>` so `removeRouteEndpoints` removes exactly what attach created. Header comment: D-09 furniture law (never orange — the pin is venue semantics), the a11y note (glyphs aria-hidden; the CARD is the semantic statement).
**`web/src/map/map.ts`** — extend the boot signature: `bootMap(container: HTMLElement, opts: { onLoad?: (map: MapLibreMap) => void } = {})`. Inside the EXISTING load handler, AFTER the building-3d try/catch and BEFORE the `RAMS-BASEMAP-READY` log: `opts.onLoad?.(map);` (its own try/catch — a route-layer failure degrades to "no route drawn", never a broken boot; log nothing new). Nothing else changes.
**`web/src/shell.css`** — append the route chrome (token references + the machined literals the UI-SPEC enumerates; NO transition/animation; NO per-card padding override):
```css
/* route state stack (second register holder — UI-SPEC §State Coexistence) */
.route-stack { position: absolute; left: var(--spacing-margin); right: var(--spacing-margin); max-width: 320px; margin-inline: auto; }
.route-stack[data-register="center"] { top: 50%; transform: translateY(-50%); }
.route-stack[data-register="bottom"] { top: auto; transform: none; bottom: calc(var(--safe-bottom, 0px) + 64px); }
/* route card body rows — token rhythm mirroring the existing .card child rules (NOT overrides) */
.route-telemetry { display: flex; align-items: baseline; gap: var(--spacing-space-xs); padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-xs); }
.route-diff { padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-xs); }
.route-chips { display: flex; gap: var(--spacing-space-xs); padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-sm); }
.route-chips:last-child { padding-bottom: var(--spacing-space-sm); }
/* chips (UI-SPEC chip contract): 28px plate, 44px hit target, instant invert */
.route-chip { position: relative; border: 0; border-radius: 0; padding: var(--spacing-space-xs) var(--spacing-space-sm); background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; white-space: nowrap; }
.route-chip::after { content: ""; position: absolute; inset: -8px; }
.route-chip:active { background: var(--color-signal-orange); color: var(--color-matte-black); }
.route-chip:focus-visible { outline: 3px solid var(--color-signal-orange); outline-offset: 2px; }
/* endpoint markers (map furniture — SVG chrome, machined geometry) */
.route-dest-namechip { position: absolute; bottom: calc(100% + var(--spacing-space-sm)); left: 50%; transform: translateX(-50%); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; padding: var(--spacing-space-xs) var(--spacing-space-sm); }
```
Update the file header's machined-literal inventory (adds: chip plate 28px/inset −8px, ring 24px/2px, pin 12px square + 3px tail, route line widths 9/5/3 map-space, the 64px composite).
**Acceptance:** `npm test -w web` still green (the RS gates for this task land in task 4); `npm run web:typecheck` exits 0.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -4 ; grep -c 'route-alt-casing' web/src/route/route-layers.ts ; grep -c 'setStyle' web/src/route/route-layers.ts</automated>
</verify>
<acceptance_criteria>
- `test -f web/src/route/route-layers.ts && test -f web/src/route/route-markers.ts` exits 0
- `grep -q '"line-join": "bevel"' web/src/route/route-layers.ts` exits 0 AND `grep -q '"line-cap": "square"' web/src/route/route-layers.ts` exits 0
- `grep -q '#1A1A1A' web/src/route/route-layers.ts` exits 0 AND `grep -q '#EB8008' web/src/route/route-layers.ts` exits 0 AND `grep -q '#D9D2C6' web/src/route/route-layers.ts` exits 0
- `grep -q 'onLoad' web/src/map/map.ts` exits 0 AND `grep -q 'opts.onLoad' web/src/map/map.ts` exits 0
- `grep -c 'setStyle' web/src/route/route-layers.ts | head -1` returns 0 (T-05-09)
- `grep -q '.route-chip::after' web/src/shell.css` exits 0 AND `grep -q 'inset: -8px' web/src/shell.css` exits 0 AND `grep -q 'route-stack' web/src/shell.css` exits 0
- `npm run web:typecheck` exits 0
</acceptance_criteria>
<done>The four-layer paint stack, endpoint markers, load-hook installation, and route chrome exist exactly per the binding UI-SPEC — style additions only, zero style re-sets.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: route cards + controller + main.ts wiring + BackButton two-state walk</name>
<files>web/src/route/route-card.ts, web/src/route/route.ts, web/src/main.ts, web/src/telegram/backbutton.ts</files>
<read_first>
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Route Card + §Router-Pending Card + §Camera Behavior + §State Coexistence + §BackButton Contract (ALL binding)
- `web/src/search/destination-card.ts` (the createElement + textContent card-builder law)
- `web/src/main.ts` (boot order, showCard, renderState, handleLive, the overlayPresent closure at line 64)
- `web/src/telegram/backbutton.ts` (the total-function wire being extended)
- `web/src/map/follow.ts` (the follow controller the walk-back reuses)
- `routing/src/index.ts` (RoutingSource / RouteResult / createFixtureRoutingSource)
</read_first>
<action>
**`web/src/route/route-card.ts`** — `buildRouteCard` + `buildRoutePendingCard` per `<module_contracts>` + the UI-SPEC structure tables. createElement + textContent ONLY. Chip buttons: `type="button"`, class `route-chip`, `aria-label` from the `ariaRouteChip` template with substitutions, `data-route-index="<originalIndex>"`; the chips row: `role="group"`, `aria-label` = `ariaRouteAlternatives`; wire `onSelect` via addEventListener reading `dataset.routeIndex`. SEARCH AGAIN: one `.btn-secondary`, text = `ctaSearchAgain`, wired to `onSearchAgain`. Register text: `strings[locale].routeRegister` when `options.length === 1`, else the composed `ROUTE {selectedIndex+1}/{N}` form (instrument constant + numbers, the Phase 3 telemetry composition precedent). NO `.btn-primary`, NO orange fill anywhere in either card (D-10; the pending card has NO red).
**`web/src/route/route.ts`** — `createRouteController` per `<module_contracts>`: constants (`ROUTE_CAMERA_PADDING`, `ROUTE_FIT_DURATION_MS = 400`, `ROUTE_CAMERA_PITCH = 35`), the idle|route|pending state machine, `consult` (auto-called once; `.catch(() => null)` → pending — T-05-04), `onSelect` swap+rebuild+refit, `walkBack` (the single total function shared with SEARCH AGAIN). Bounds from geometry points (min/max lat/lng — `[[lngMin,latMin],[lngMax,latMax]]` for fitBounds). The two `ARAHARAH-ROUTE state=` console lines are the module's ONLY console calls. Header comment: D-03/D-08/D-09/D-10 laws + the memory-only origin law.
**`web/src/telegram/backbutton.ts`** — extend the wire (ONE change, predicate + handler together — UI-SPEC):
```typescript
export function wireBackButton(
wa: TelegramWebAppLike,
hasOverlay: () => boolean,
walkBack?: () => void,
): void {
wa.BackButton?.hide();
wa.BackButton?.onClick(() => {
if (hasOverlay()) { walkBack?.(); return; } // a pushed state: walk to the root, never close()
wa.close(); // Telegram semantics: back at the root closes the app
});
}
```
Update the header comment: Phase 5 gives the predicate its first real states (route or pending); Phase 6 extends the graph.
**`web/src/main.ts`** — the boot wiring (surgical; Phase 3 paths byte-identical when no destination resolved):
1. Replace line 64's closure with ONE assignable phase closure (the single source of truth for the overlay predicate AND the stale branch): `let routePhase: () => "idle" | "route" | "pending" = () => "idle";` — `wireBackButton(wa, () => routePhase() !== "idle", () => routeWalkBack());` with `let routeWalkBack = (): void => {};` declared beside it (both assigned for real in step 3, after the controller exists).
2. After the Phase 3 `.card-stack` creation, create the route stack: `const routeStack = document.createElement("div"); routeStack.className = "route-stack"; routeStack.style.display = "none"; mainEl.appendChild(routeStack);` with a local `showRouteCard(card, register)` that replaces children + sets `data-register` + display (mirroring showCard's shape; center = pending, bottom = route card).
3. After the guarded `map !== null` block: build the route surface — `const routingSource = createFixtureRoutingSource();` + `const routeController = createRouteController({ map, source: routingSource, buildRouteCard, buildPendingCard: buildRoutePendingCard, showRouteCard, showDestinationCard: () => showCard(destinationCard), follow: { follow: (fix) => follow?.follow(fix) }, getLatestFix: () => lastFix, onStateChange: (s) => { routePhase = () => s; if (wa) s === "idle" ? wa.BackButton?.hide() : wa.BackButton?.show(); } });` — the `follow` dep is the NULL-TOLERANT WRAPPER `{ follow: (fix) => follow?.follow(fix) }` (the controller's types see a total object; the WebGL2-absent shell degrades to cards-only). Then `routeWalkBack = () => routeController.walkBack();`.
4. In `handleLive(fix)`: after `renderState({ kind: "live", fix })`, add the auto-consult guard: `if (destination !== null && !routeConsulted) { routeConsulted = true; void routeController.consult(fix, destination); }` — `let routeConsulted = false;` beside the other boot flags. (destination-unavailable and no-destination boots NEVER consult — the state-coexistence law.)
5. In `renderState`'s `stale` branch, branch on the phase: `routePhase() === "route"` → do NOT touch either stack (the route card persists in the route stack; the strip lamp + dimmed marker carry the signal — the UI-SPEC stale-during-route row); `routePhase() === "pending"` → the pending card stays in the route stack AND `showCard(lostCard, "bottom")` fires as today (different registers coexist); `routePhase() === "idle"` → exactly the Phase 3 behavior.
6. `renderState`'s `live` branch stays untouched (the destination card holds the register until the route surface takes over via its own stack).
The SEARCH AGAIN button inside the route/pending cards calls `onSearchAgain → routeController.walkBack()` — the same total function the BackButton walks.
**Acceptance:** `npm run web:typecheck` exits 0; `npm test -w web` still green (RS gates land in task 4).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -4 ; grep -c 'ARAHARAH-ROUTE' web/src/route/route.ts</automated>
</verify>
<acceptance_criteria>
- `grep -q 'createFixtureRoutingSource' web/src/main.ts` exits 0 AND `grep -q 'createRouteController' web/src/main.ts` exits 0
- `grep -q 'walkBack?: () => void' web/src/telegram/backbutton.ts` exits 0 AND `grep -q 'route-stack' web/src/main.ts` exits 0
- `grep -q 'ARAHARAH-ROUTE state=drawn' web/src/route/route.ts` exits 0 AND `grep -q 'ARAHARAH-ROUTE state=pending' web/src/route/route.ts` exits 0
- `grep -q 'routeConsulted' web/src/main.ts` exits 0 (consult-once guard)
- `grep -q 'innerHTML' web/src/route/route-card.ts | grep -v '//'` returns no match OR zero count (createElement + textContent only — T-05-10)
- `grep -q 'ROUTE_CAMERA_PADDING' web/src/route/route.ts` exits 0 AND `grep -q 'bottom: 320' web/src/route/route.ts` exits 0
- `npm run web:typecheck` exits 0
</acceptance_criteria>
<done>The route state machine, cards, and boot wiring exist: auto-consult after live+destination, bottom route card with chips, center pending card, camera fit-bounds law, and the two-state BackButton walk sharing SEARCH AGAIN's total function.</done>
</task>
<task type="auto" tdd="true">
<name>task 4: the RS structural + fake-map behavioral suite</name>
<files>web/test/route-surface.test.mjs</files>
<read_first>
- `web/test/destination-preload.test.mjs` (the DP-gate style: lazy imports, named cases, comment-stripped source scans)
- `web/test/no-storage.test.mjs` (the stripComments pattern RS2/RS8 reuse)
- `web/src/route/route-layers.ts` + `web/src/route/route.ts` + `web/src/route/route-card.ts` (tasks 2–3 outputs — the gates assert THEIR text and behavior)
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` (the laws the gates pin)
</read_first>
<action>
**`web/test/route-surface.test.mjs`** — RED-first where behavioral (fake-map cases run against real modules), source-text where structural. Cases:
- `RS1 paint law` — `route-layers.ts` text contains the 4 layer ids IN ORDER, widths 9/5/5/3, colors `#1A1A1A`/`#EB8008`/`#D9D2C6`, `"bevel"`, `"square"`, `"line-opacity": 1` (D-09).
- `RS2 map-law` — comment-stripped `web/src/route/*.ts` contains ZERO `setStyle` (T-05-09); `map.ts` text: `opts.onLoad` appears AFTER the `building-3d` marker and BEFORE the `RAMS-BASEMAP-READY` string (source-order assertions via indexOf).
- `RS3 fake-map layers` — a recording fake MapLike: `installRouteLayers(fake)` adds exactly 2 sources then 4 layers in order; a second call is a NO-OP (idempotence); `setRouteDrawn` calls setData on both sources with `{ type: "FeatureCollection", features: [...] }` (selected → "route", 1 feature per alt → "route-alt"); `clearRouteLayers` sets both to empty feature collections.
- `RS4 controller state machine` — real `createRouteController` + fake deps (fake source serving a synthetic 2-option RouteResult, stub card builders returning `{className:"",textContent:""}`-shaped records, recording showRouteCard): `consult` → route card shown with register "bottom", console carries `ARAHARAH-ROUTE state=drawn`, `fitBounds` called with padding `{top:96,bottom:320,left:64,right:64}`, `duration: 400`, `pitch: 35`, `bearing` from `getBearing()`; select alt → setData swap + a NEW card build + fitBounds again (always refits); `walkBack` → clearRouteLayers called + showDestinationCard called + state "idle" + BackButton hidden (onStateChange). Pending: source returns null → pending card register "center", `state=pending` logged, fitBounds NEVER called; a source that THROWS behaves identically (fail-honest, T-05-04).
- `RS5 card structure (source law)` — `route-card.ts` text: zero `innerHTML`/`insertAdjacentHTML`; contains `role", "group"` (the chips group), `route-chip`, `btn-secondary`, and `routeRegister`; contains NO `btn-primary` (D-10: no orange CTA fill on any Phase 5 surface).
- `RS6 D-10 absence law` — `web/src/route/*.ts` comment-stripped: ZERO `btn-primary`, zero `progress`, zero `maneuver` (no guidance furniture — D-10).
- `RS7 BackButton walk` — real `wireBackButton` + a fake `wa` (the mock's BackButton shape): overlay true + walkBack spy → click invokes walkBack and NEVER `close()`; overlay false → click invokes `close()` (T-05-11).
- `RS8 console discipline` — comment-stripped `web/src/route/*.ts`: the ONLY `console.` occurrences are the two `ARAHARAH-ROUTE state=` literals; no console call contains a template interpolation (D-15 hygiene, T-05-13).
- `RS9 strings-only copy` — `web/src/route/*.ts` contains ZERO of the pending-card English sentences (the copy lives only in strings.ts — COPY5's companion check); `route.ts` imports from `../i18n/strings.ts` transitively via the card module (assert the card module's import line exists).
- `RS10 no-storage floor bump` — reuse no-storage's file-list shape: assert the `web/src/route` directory exists and its `.ts` files are included in a scan totalling ≥ 16 files (the GEO3 gate covers them automatically; this pins the family's presence).
**Acceptance:** `npm test -w web` exits 0 with `PASS RS1`…`PASS RS10` (plus every earlier suite).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -20 ; npm run web:typecheck 2>&1 | tail -4</automated>
</verify>
<acceptance_criteria>
- `npm test -w web` exits 0 and the output contains `PASS RS1` through `PASS RS10`
- `npm run web:typecheck` exits 0
- `grep -q 'RS4' web/test/route-surface.test.mjs` exits 0 (the fake-map behavioral case exists)
- `grep -c 'setStyle' web/src/route/*.ts | grep -v ':0' | wc -l | tr -d ' '` returns 0 (zero setStyle across the route family)
</acceptance_criteria>
<done>Ten RS gates pin the UI-SPEC laws mechanically: paint, map-law, layer idempotence, the full controller state machine (drawn/pending/exception/walk-back), card structure, D-10 absences, the BackButton walk, console discipline, strings-only copy, and the route family's presence under the storage gate.</done>
</task>
</tasks>
<verification>
1. `npm test -w web` — all suites green including COPY1/COPY5/COPY7 and RS1–RS10.
2. `npm run web:typecheck` — exit 0.
3. `npm run build -w theme && npm run build -w web` — production build succeeds with the route family included (the 05-05 gates measure it).
4. Manual structural sweep: `grep -rn "setStyle\|btn-primary\|innerHTML" web/src/route/` → zero code matches (comments stripped).
5. The UI-SPEC cross-check: layer widths 9/5/5/3, camera padding 96/320/64/64 @ 400ms, card bottom offset 64px composite, chips 28px/44px — each value greps to exactly the spec number.
</verification>
<success_criteria>
- The route state machine draws the selected route (orange casing stack), swaps alternatives on chip tap (instant + refit), renders the designed pending card for uncovered pairs, and walks back cleanly via BackButton or SEARCH AGAIN.
- Zero style re-sets, zero innerHTML, zero guidance furniture, zero coordinate-bearing console lines.
- All copy lives in the strings module (COPY7 verbatim-pinned, en + ms).
- The RS suite proves every UI-SPEC law that is provable without a browser; the capture legs (05-05) prove the pixels.
</success_criteria>
@@ -0,0 +1,270 @@
---
phase: 05-routing-route-display
plan: 05
type: execute
wave: 3
depends_on: ["02", "04"]
files_modified:
- web/test/build-artifact.test.mjs
- web/index.html
- web/src/mock/route-profiles.ts
- web/scripts/capture.mjs
- bot/test/route-privacy.test.mjs
autonomous: true
requirements: [RTNG-01, RTNG-02]
must_haves:
decisions_implemented:
- "D-11: the BA build-artifact bundle-budget gate — fixture geometry + route-model code ≤ ~200KB gzipped measured over production-shaped artifacts, printed with every run"
- "D-05: the Worker gains no /api/route — a structural gate proves bot/src never registers a route proxy and never imports the routing workspace"
- "D-15: capture signals are state tokens only — ARAHARAH-ROUTE state=drawn|pending, never coordinates; the capture legs pair the token with DOM text checks"
- "D-12: the capture harness's ?fixpair knob delivers a PINNED PUBLIC origin from the committed fixture corpus — the harness introduces no user coordinate"
- "DSGN-02: the route-gallery fidelity artifact extends the Phase 1/3 gallery pattern beside navigate_map_overview/screen.png"
truths:
- "BA9 measures the gzipped size of the fixture dataset + routing workspace sources + web route modules against a 204800-byte budget and PRINTS the measurement — the budget is visible, never discovered"
- "BA10 proves every committed pairId rides the shipped production bundle (the BA7 id-parity analog — a stale bundle without the fixtures is unshippable)"
- "The harness-only route-profiles module is provably excluded from web/dist (a named sentinel token the BA5-family scan greps for)"
- "Five new capture legs exist and pass: shell-route-fixture, shell-route-alt (post-chip-tap), shell-route-pending, shell-route-dark, route-gallery-compare — all non-blank, all state-token-raced"
- "bot/test/route-privacy.test.mjs proves the Worker stayed out of the routing path: zero /api/route tokens, zero @araharah/routing imports in bot/src"
artifacts:
- path: "web/test/build-artifact.test.mjs"
provides: "BA9 (bundle budget, printed) + BA10 (pairId parity) — the production-build gates"
contains: "BA9"
- path: "web/src/mock/route-profiles.ts"
provides: "the harness-only ?fixpair origin lookup (imports getPairOrigin from @araharah/routing; sentinel-guarded)"
contains: "ROUTE_PROFILE_HARNESS_ONLY"
- path: "web/index.html"
provides: "the ?fixpair=<pair-id> knob — the mock's telegramFix becomes the pair's pinned public origin"
contains: "fixpair"
- path: "web/scripts/capture.mjs"
provides: "the five route capture legs + the ARAHARAH-ROUTE signal race + DOM assertions"
contains: "ARAHARAH-ROUTE"
- path: "bot/test/route-privacy.test.mjs"
provides: "RP1/RP2 — the Worker-side no-route-proxy gates (D-05)"
contains: "RP1"
key_links:
- from: "web/index.html"
to: "web/src/mock/route-profiles.ts"
via: "the harness block resolves ?fixpair to a pinned origin and threads it as the mock's telegramFix"
pattern: "getRouteProfileOrigin"
- from: "web/src/mock/route-profiles.ts"
to: "@araharah/routing/src/index.ts"
via: "getPairOrigin(pairId) — the committed public fixture corpus is the ONLY origin source the harness can deliver"
pattern: "getPairOrigin"
- from: "web/scripts/capture.mjs"
to: "web/src/route/route.ts"
via: "the capture race waits on the ARAHARAH-ROUTE state token the controller emits"
pattern: "ARAHARAH-ROUTE"
- from: "bot/test/route-privacy.test.mjs"
to: "bot/src"
via: "the D-05 structural scan (no route proxy, no routing import) — the privacy-gate family pattern"
pattern: "/api/route"
---
<objective>
Close the phase with its verification gates: the BA9 bundle-budget + BA10 fixture-parity build-artifact gates over the production build, the five route capture legs driven by the new `?fixpair=` harness knob, and the Worker-side no-route-proxy privacy gates — the mechanical proof that the amended SC3 holds ("bundle-size and type gates holding").
Purpose: D-11's bundle budget is a build-artifact gate, not a hope; the UI-SPEC's five capture artifacts are the phase's visual evidence (and the DSGN-02 fidelity obligation); and D-05/D-12's privacy posture stays verifiable by reading gate output. The route legs run against the production-shaped harness bundle (the Phase 3 capture discipline — never against dev-mode).
Output: BA9/BA10 in `build-artifact.test.mjs`, `web/src/mock/route-profiles.ts` + the `?fixpair` knob in `web/index.html`, five new capture legs in `capture.mjs` (expected list 8 → 13), and `bot/test/route-privacy.test.mjs` (RP1/RP2).
</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-UI-SPEC.md
@.planning/phases/05-routing-route-display/05-02-PLAN.md
@.planning/phases/05-routing-route-display/05-04-PLAN.md
@web/test/build-artifact.test.mjs
@web/scripts/capture.mjs
@web/index.html
@web/src/mock/telegram-mock.ts
@bot/test/privacy.test.mjs
@routing/src/index.ts
@routing/src/data/route-fixtures.json
<capture_contract>
The five new legs (UI-SPEC §Capture Profiles — ALL existing legs unchanged; the expected list grows 8 → 13). The demo pair is selected AT RUNTIME from the committed corpus — never hardcoded: `const routePairs = JSON.parse(readFileSync(resolve(REPOROOT, "routing/src/data/route-fixtures.json", "utf8"))).pairs;` then `const demoPair = routePairs.find((p) => p.routes.length >= 2) ?? routePairs[0];` (RT10 guarantees ≥ 4 multi-route pairs, so the find succeeds; the fallback keeps the legs runnable in the degenerate case). Queries are then built from `demoPair.destinationId` / `demoPair.pairId`:
| Artifact | Query | Race + assertions |
|----------|-------|-------------------|
| `shell-route-fixture.png` | `mock=android&dest=${demoPair.destinationId}&fixpair=${demoPair.pairId}` (light) | race `ARAHARAH-ROUTE`; assert the line reads `state=drawn`; DOM: `waitForFunction` body text contains the destination's NAME (from the gazetteer entry read at capture time) AND `MIN` (the card's telemetry); 2000ms tile settle; shot |
| `shell-route-alt.png` | same profile | after the drawn race: if `.route-chip` exists (multi-route pair — the RT10-guaranteed case), `page.click(".route-chip")`, wait 800ms (instant swap + 400ms refit + margin), assert body text matches `/ROUTE \d\/\d/`; ELSE (degenerate single-route fallback) assert the no-chip law: the page contains NO `.route-chip` element; shot |
| `shell-route-pending.png` | `mock=android&dest=national-museum&fixpair=${demoPair.pairId}` | race `ARAHARAH-ROUTE`; assert `state=pending`; DOM: body contains `not wired up yet` (the pending headline, en); shot |
| `shell-route-dark.png` | route-fixture profile + `scheme=dark` | same race + the PX-02 honesty check (`data-tg-theme` === "dark" before shooting); shot |
| `route-gallery-compare.png` | — | the DSGN-02 composer (gallery-compare precedent): `shell-route-fixture.png` beside `stitch_minimalist_route_navigator/navigate_map_overview/screen.png`, columns labeled with source paths |
Harness mechanics:
- `SIGNALS` gains `'ARAHARAH-ROUTE'` (the seen-map + diagnostics strings too).
- New `waitMode: 'route'` — race the ARAHARAH-ROUTE console event (30s timeout, the SIGNAL_TIMEOUT_MS shape), then parse the captured line for `state=` and let the leg assert its expected state + DOM.
- `web/index.html` harness block: `const fixpair = q.get("fixpair");` — when present, import the profile module and resolve the pinned origin; pass `telegramFix: { lat: <origin.lat>, lng: <origin.lng>, accuracyM: 12, headingDeg: null }` (fall back to the current default fix when the pairId is unknown — an unknown pairId must not wedge the boot).
- `web/src/mock/route-profiles.ts` (HARNESS-ONLY, imported ONLY from the index.html harness block): imports `getPairOrigin` from `@araharah/routing/src/index.ts`; exports `getRouteProfileOrigin(pairId: string): { lat: number; lng: number } | null` and the sentinel `export const ROUTE_PROFILE_HARNESS_ONLY = true;`. Header comment: D-12 (the pinned origin is committed public fixture data — the harness delivers no user coordinate) + the BA-exclusion note.
</capture_contract>
<ba_gate_contract>
**BA9 — the bundle budget (D-11).** Appended to `build-artifact.test.mjs`:
- Inputs (all committed files, production-shaped by construction): `routing/src/data/route-fixtures.json`, every `routing/src/**/*.ts`, every `web/src/route/**/*.ts`.
- Measurement: sum of `gzipSync(readFileSync(f)).length` over the inputs; assert `<= 204800` (200 KiB); the failure message and a `console.log` line BOTH print the measured bytes + budget (`BA9 route bundle budget: {n} / 204800 gz bytes`) — visible every run (D-11: "enforced as a BA build-artifact gate").
- Comment in the gate: the measurement bounds the phase's ADDED payload (fixtures dominate; the minified route code's gz size is approximated by its source gz size — the approximation is conservative in the gate's favor at these sizes and the 200 KiB ceiling carries an order-of-magnitude headroom per 05-RESEARCH Finding 1).
- The gate skips with a build-first message when `web/dist` is absent (the BA-suite convention) — run after `npm run build -w theme && npm run build -w web`.
**BA10 — fixture parity (the BA7 analog).** Every `pairId` from the committed `route-fixtures.json` must appear in the concatenated `web/dist/assets/*.js` text — a stale bundle that lost the fixtures could never serve a route (assert `pairs.length >= 10` first, the "scanned a real dataset" guard pattern).
**BA5-family extension.** Add `"ROUTE_PROFILE_HARNESS_ONLY"` to BA5's token list — the harness-only route-profiles module must be dead-code-eliminated from production (its import lives inside `if (__ARAHARAH_HARNESS__)` in index.html; the define-replaces-with-false mechanism strips the branch exactly like installMockWebApp).
</ba_gate_contract>
<environment_facts>
Verified in this session — do not re-derive:
- `capture.mjs` structure: `SIGNALS` array at line 166, `captureLeg({name, query, colorScheme, grantGeo, waitMode})`, the waitMode dispatch chain (`live`/`dest`/`denied`/`settle400`/`immediate`), and the final `expected` list of 8 artifacts with the missing/undersized/failures gate — the extension points are exactly these four.
- `web/index.html`'s harness block already reads `?dest=` and threads it as `startParam`; `telegramFix` is hard-coded `{lat:3.1513,lng:101.6958}` today — the fixpair knob replaces its VALUE (never the shape).
- `build-artifact.test.mjs` reads `search/src/data/kl-gazetteer.json` via `import … with { type: "json" }` (line 47) — the fixtures JSON imports identically; BA7's per-id loop is the BA10 template.
- The mock's `telegramFix` delivery maps `{lat,lng,accuracyM,headingDeg}` → the Telegram wire shape (`telegram-mock.ts` lines 190–199) — the profile module returns the authoring shape.
- Plan 05-02 pinned `national-museum` as the pending-probe destination (RT4: never a corpus destination) — the pending leg's carrier is valid by construction.
- Plan 05-04's controller emits exactly two console tokens: `ARAHARAH-ROUTE state=drawn` / `ARAHARAH-ROUTE state=pending` (RS8-pinned).
- `bot/test/privacy.test.mjs` is the bot-side grep-gate style reference (comment-stripping + banned-token scans).
</environment_facts>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Harness query params → mock fix | `?fixpair=` selects which PUBLIC pinned origin the mock delivers. It must never accept arbitrary coordinates (a `?lat=&lng=` knob would reintroduce the coordinate-in-URL surface the fixture world exists to avoid). |
| Committed artifacts → the measured budget | The budget gate reads repo files; a silent fixture bloat (someone re-fetching with `overview=full&steps=true`) must fail the build gate, not surface as a slow webview. |
| Production build → users | Harness-only modules leaking into `web/dist` would ship mock wiring — the BA5-family sentinel scan is the tripwire. |
| Worker surface | A route proxy appearing in bot/src would violate D-05's "the Worker gains no /api/route" — the structural gate keeps the pivot honest. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-12 | Denial of Service (availability budget) | Fixture/code growth pushing the webview bundle past the D-11 budget | mitigate | BA9 fails the suite above 204800 gz bytes and prints the measurement every run; RT9 (plan 05-02) is the fixture-level early-warning tier of the same law. |
| T-05-14 | Information Disclosure | A coordinate-parameter harness knob or a coordinate-bearing capture signal reintroducing user-coordinate surfaces | mitigate | The knob accepts ONLY a pairId (resolved from the committed public corpus via `getPairOrigin` — arbitrary lat/lng params are never read); the capture legs assert the `state=` token form (no coordinates — RS8 pins the source; the legs pin the runtime); the pending leg proves an out-of-corpus pair renders the honest card, never a fabricated geometry. |
| T-05-16 | Tampering (D-05 regression) | A route proxy growing into the Worker | mitigate | RP1: zero `/api/route` tokens in comment-stripped `bot/src`; RP2: zero `@araharah/routing` imports in `bot/src` — the Worker cannot route even accidentally. |
| T-05-15 | Tampering (harness leakage) | The route-profiles harness module shipping in production | mitigate | The `ROUTE_PROFILE_HARNESS_ONLY` sentinel joins BA5's token scan — the module is provably dead-code-eliminated from `web/dist`. |
</threat_model>
<tasks>
<task type="auto" tdd="true">
<name>task 1: BA9 bundle-budget + BA10 fixture-parity + BA5 sentinel extension</name>
<files>web/test/build-artifact.test.mjs</files>
<read_first>
- `web/test/build-artifact.test.mjs` (BA1–BA8 — the gate file this task extends; BA7's loop is the BA10 template)
- `routing/src/data/route-fixtures.json` (the committed pair set BA10 asserts)
- `.planning/phases/05-routing-route-display/05-RESEARCH.md` (Finding 1 — the size math behind the budget)
</read_first>
<action>
Extend `web/test/build-artifact.test.mjs`:
- Import the fixtures dataset (`import routeFixtures from "../../routing/src/data/route-fixtures.json" with { type: "json"; }`) and `gzipSync` from `node:zlib`.
- Define the input file list: `routing/src/data/route-fixtures.json` + a recursive walk of `routing/src` (`*.ts`) + `web/src/route` (`*.ts`) (resolve paths relative to the test file like `WEB`/`DIST` are).
- `BA9` test: skip-with-build-first-message when `web/dist` is absent (the suite convention); compute the summed gz size; `console.log("BA9 route bundle budget: " + total + " / 204800 gz bytes")`; assert `<= 204800` with the measurement in the failure message.
- `BA10` test: assert `routeFixtures.pairs.length >= 10` (a real dataset guard); concatenate `web/dist/assets/*.js`; assert EVERY `pairId` appears in the bundle text (failure names the missing pairId — the T-04-39 phrasing pattern).
- BA5: add `"ROUTE_PROFILE_HARNESS_ONLY"` to the scanned token list (both the JS-assets and the HTML scans).
- Update the file-header doc-comment inventory (BA9/BA10 lines).
Build first, then run: `npm run build -w theme && npm run build -w web && npm test -w web` — ALL BA gates green including the new ones (04-04's BA7/BA8 still pass — the route family adds no crypto, no CDN, and the gazetteer ids remain).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run build -w theme >/dev/null 2>&1 && npm run build -w web >/dev/null 2>&1 ; npm test -w web 2>&1 | tail -15</automated>
</verify>
<acceptance_criteria>
- `npm test -w web` exits 0 and the output contains `PASS BA9` and `PASS BA10`
- `grep -q 'BA9 route bundle budget' web/test/build-artifact.test.mjs` exits 0 (the printed measurement)
- `grep -q '204800' web/test/build-artifact.test.mjs` exits 0
- `grep -q 'ROUTE_PROFILE_HARNESS_ONLY' web/test/build-artifact.test.mjs` exits 0 (BA5 extension)
- `grep -q 'pairId' web/test/build-artifact.test.mjs` exits 0 (BA10 parity loop)
- `node -e "const{gzipSync}=require('node:zlib');const{readFileSync,readdirSync}=require('node:fs');const{join}=require('node:path');let t=0;const w=(d)=>{for(const f of readdirSync(d,{withFileTypes:true})){const p=join(d,f.name);if(f.isDirectory())w(p);else if(f.name.endsWith('.ts'))t+=gzipSync(readFileSync(p)).length}};w('routing/src');t+=gzipSync(readFileSync('routing/src/data/route-fixtures.json')).length;w('web/src/route');console.log('measured:',t);if(t>204800)throw new Error('over budget');console.log('BA9 preview OK')"` exits 0
</acceptance_criteria>
<done>The production build passes the budget gate (measured and printed), carries every committed pairId, and provably excludes the harness-only profile module.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: ?fixpair harness knob + five route capture legs</name>
<files>web/src/mock/route-profiles.ts, web/index.html, web/scripts/capture.mjs</files>
<read_first>
- `web/index.html` (the harness block — where the knob threads)
- `web/src/mock/telegram-mock.ts` (MockOptions.telegramFix — the authoring shape the profile feeds)
- `web/scripts/capture.mjs` (SIGNALS, captureLeg waitModes, the expected list, the gallery composer)
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Capture Profiles (the binding artifact table)
</read_first>
<action>
**`web/src/mock/route-profiles.ts`** — per the `<capture_contract>` block: sentinel export + `getRouteProfileOrigin` wrapping `getPairOrigin` from `@araharah/routing/src/index.ts`; header comment citing D-12 and the BA5 sentinel.
**`web/index.html`** — inside the harness block: read `const fixpair = q.get("fixpair");`; after the existing mock-import, when `fixpair` is truthy dynamically import the profile module (`await import("/src/mock/route-profiles.ts")` — the `/src/` absolute form is the PROVEN harness-import path), resolve the origin, and use it as the mock's `telegramFix` lat/lng (accuracy 12, heading null); unknown pairId → keep the current default fix (log nothing). Add `fixpair` to the profile comment line listing the knobs.
**`web/scripts/capture.mjs`**:
- `SIGNALS` gains `'ARAHARAH-ROUTE'`; the seen-map + `signalDiagnostics` gain the same key.
- At the top of the leg block, load the committed corpus and pick the demo pair (`routePairs` / `demoPair` per the `<capture_contract>` block — the legs are data-driven from the committed dataset, never hardcoded pair literals).
- New `waitMode: "route"` branch: race the `ARAHARAH-ROUTE` console event (`SIGNAL_TIMEOUT_MS`); on timeout → hard failure with diagnostics; parse `state=` from the captured line; the leg's expected state + DOM assertions are leg parameters (`expectState`, `expectText: […]` — `waitForFunction` over `document.body.textContent`, 15s, the dest-leg polling shape); 2000ms settle before the shot (tile render).
- Add the five legs per the `<capture_contract>` table (fixture → alt (chip click when present) → pending → dark → gallery-compare, reusing the existing composer with `shell-route-fixture.png` in the implemented column).
- Extend `expected` to the 13-artifact list. Update the header doc-comment's Outputs inventory.
The alt leg's chip click: `page.click(".route-chip")` (first chip), then assert `/ROUTE \d\/\d/.test(bodyText)` — the `{n}/{N}` register the swapped card renders; the degenerate no-chip fallback asserts the single-route rendering law instead (UI-SPEC: single-route draws render NO chip row).
**Acceptance:** `npm run web:capture` exits 0 with 13/13 non-blank captures (`CAPTURE OK — 13/13`), including the five new legs; no existing leg regressed.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run web:capture 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `npm run web:capture` exits 0 and its output contains `CAPTURE OK — 13/13`
- `test -f web/screenshots/shell-route-fixture.png && test -f web/screenshots/shell-route-alt.png && test -f web/screenshots/shell-route-pending.png && test -f web/screenshots/shell-route-dark.png && test -f web/screenshots/route-gallery-compare.png` exits 0
- `grep -q 'ARAHARAH-ROUTE' web/scripts/capture.mjs` exits 0 AND `grep -q 'fixpair' web/index.html` exits 0
- `grep -q 'ROUTE_PROFILE_HARNESS_ONLY' web/src/mock/route-profiles.ts` exits 0
- `grep -q 'getPairOrigin' web/src/mock/route-profiles.ts` exits 0 (the pinned-corpus-only origin law)
- the runner-page profile comment lists the fixpair knob: `grep -q '?fixpair' web/index.html` exits 0
</acceptance_criteria>
<done>Five route capture legs pass against the production-shaped harness bundle — drawn, alt-swapped, pending, dark, and the DSGN-02 gallery artifact — all raced on state tokens with DOM text assertions.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: Worker-side no-route-proxy gates (D-05)</name>
<files>bot/test/route-privacy.test.mjs</files>
<read_first>
- `bot/test/privacy.test.mjs` (the bot-side grep-gate style: comment stripping, banned-token scans, named cases)
- `bot/src` tree (the scanned surface)
- `.planning/phases/05-routing-route-display/05-CONTEXT.md` D-05 (the law being gated)
</read_first>
<action>
**`bot/test/route-privacy.test.mjs`** — RED-first-then-green (pure gates over the unmodified Worker tree):
- `RP1 no route proxy` — strip comments from every `bot/src/**/*.ts` (the privacy.test.mjs pattern) and assert ZERO occurrences of `/api/route` and `route` handler registrations matching `/app\.route\(|on\("message:url"|\.route\("route/`… keep it PRECISE: assert zero `/api/route` tokens AND zero `registerRoute|routeProxy` tokens (D-05: the Worker gains no route endpoint this phase).
- `RP2 no routing import` — zero `@araharah/routing` specifiers anywhere in `bot/src` (routing lives in the webview bundle only; the Worker imports only `@araharah/search`).
Header comment: the D-05 citation + "the pivot's Worker boundary, gated".
**Acceptance:** `npm test -w bot` exits 0 with `PASS RP1` and `PASS RP2` (all existing bot suites + GE gates still green).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` exits 0 and the output contains `PASS RP1` and `PASS RP2`
- `grep -rc '/api/route' bot/src --include='*.ts' | grep -v ':0' | wc -l | tr -d ' '` returns 0 (the gate's premise holds in the tree itself)
- `grep -rc '@araharah/routing' bot/src --include='*.ts' | grep -v ':0' | wc -l | tr -d ' '` returns 0
</acceptance_criteria>
<done>The Worker's routing abstinence is mechanically gated: no route endpoint, no routing-workspace import — the pivot's D-05 boundary holds by test, not by memory.</done>
</task>
</tasks>
<verification>
1. `npm run build -w theme && npm run build -w web && npm test -w web` — BA1–BA10 all green (BA9 prints the measurement).
2. `npm run web:capture` — CAPTURE OK — 13/13 (the five new legs non-blank, state-raced, DOM-asserted).
3. `npm test -w bot` — all suites green including GE1–GE7 and RP1/RP2.
4. Full suite: `npm run routing:test && npm run routing:corpus && npm test -w web && npm test -w bot` — green.
5. The amended SC3 evidence chain: RT1–RT10 (corpus routes cleanly) + BA9/BA10 (bundle-size gate) + the 05-01 C2 type gate + the pending capture (deliberate, designed) — all four clauses have a green instrument.
</verification>
<success_criteria>
- BA9/BA10 pass over the production build with the measurement printed.
- 13/13 captures pass including the five route legs driven by ?fixpair.
- RP1/RP2 gate the Worker out of the routing path.
- The phase's full verification story is green across all four workspaces.
</success_criteria>
@@ -0,0 +1,115 @@
# Phase 5: Routing + Route Display - Context
**Gathered:** 2026-10-24
**Status:** Ready for planning — **with a roadmap amendment** (SC3 rewording, below)
<domain>
## Phase Boundary
The route EXPERIENCE ships end-to-end in fixture world: a `RoutingSource` interface (mirroring `SearchSource`), a fixture backend whose geometries for 10–20 real KL pairs were precomputed at build time, Rams route rendering (orange casing on the grayscale map), up-to-3 alternatives as a bottom card + chips, selection redraw with camera refit, and an honest "router pending" state for arbitrary origins. The `guidance_sessions` envelope is designed as a types-only frozen contract for Phase 6. **No VPS, no Worker route proxy, no third-party routing calls** — the engine itself is deferred to the WASM-router OSS track (client-side routing, backend #2), with Valhalla-on-VPS documented as backend #3.
**ROADMAP AMENDMENT (planning must apply):** SC3 is reworded from "routes cleanly through self-hosted Valhalla … behind the Worker proxy" to: "the golden-route corpus (10–20 real KL pairs) routes cleanly through the fixture RoutingSource backend with bundle-size and type gates holding; the RoutingSource interface carries the documented backend ladder (fixtures → client-side WASM router → Valhalla-on-VPS) and the 'router pending' state is deliberate and designed." RTNG-01's "calculated route" is satisfied by fixture calculation; the arbitrary-origin router is a documented deferral, not an omission.
</domain>
<decisions>
## Implementation Decisions
### Routing infrastructure — THE PIVOT (user decision, supersedes roadmap SC3 v1)
- **D-01:** **No VPS this phase. No third-party routing API ever at runtime** (origin+destination to a third party is a core-value privacy leak). Routing is a **`RoutingSource` interface** with a backend ladder: **#1 fixtures (this phase) → #2 client-side WASM router (OSS track, deferred) → #3 Valhalla-on-VPS (documented, provision scripts as a documented path, not shipped)**.
- **D-02:** Backend #1 = **fixture routes for the 10–20 golden KL pairs**, geometries precomputed at BUILD time by a repo script hitting the public OSRM demo server once (build machine, fixed public coordinates — no user data, no runtime dependency), committed as compact encoded polylines. Each pair pins expected distance/duration tolerances.
- **D-03:** Arbitrary origin/destination combinations return an honest **"router pending"** state — a designed, deliberate Rams surface (UI-SPEC scope), never a blank map, never a fake route, never a silent failure.
- **D-04:** The **WASM-router track** (client-side routing in the webview — origin+destination never touch a server; static-hosted; fork-perfect) is captured as the post-v1 OSS project that becomes RoutingSource backend #2, exactly parallel to the search backend #2 pattern. The user's static-hosting/fork-model intent is preserved as the architectural direction, not abandoned.
- **D-05:** The Worker gains **no `/api/route`** this phase. Fixtures ride in the web bundle (gazetteer precedent). The interface stays backend-shaped so a server backend could slot in later without client rework.
### guidance_sessions envelope (Phase 6's contract, frozen now)
- **D-06:** **Types-only contract**: `guidance_sessions` envelope = { id, chat ref, route snapshot (inline), created_at } with a **~2h TTL** — shipped as code types + docs, **no KV namespace, no service, no runtime writes**. Phase 6 builds the service on the frozen shape.
- **D-07:** Route snapshot lives **inline in the envelope record — one key, one TTL**; nothing outlives 2h, nothing reconstructable after expiry. Consistent with the location-pin TTL precedent (Phase 3). The route model itself carries **step geometry + snapped-progress support fields from day one** (GUID-05/06 upstream obligation).
### Route UX surface (UI-SPEC scope; phase-terminal)
- **D-08:** Alternatives present as a **bottom card + chips**: one card for the selected route (time, distance, one-line difference vs best) with compact alternates as selectable chips — tap → redraw. Map stays dominant.
- **D-09:** Route line = **signal-orange casing** (dark under-line for grayscale contrast) + orange center line — Phase 1's reserved accent, per the navigate_map_overview mockup. Destination pin keeps venue semantics; origin = subdued ring. Camera **fits route bounds** on draw.
- **D-10:** **Display-only terminal surface**: selection redraws + camera refits; a restart/search-again affordance returns to the destination state. **No start-guidance button, no turn instructions, no progress UI** — Phase 6. No placeholder-shaped furniture (Phase 3 D-08 law).
### Gates replacing SC3's latency/subrequest budgets
- **D-11:** **Bundle budget**: fixture geometry + route-model code ≤ ~200KB gzipped added to the web bundle, enforced as a BA build-artifact gate. **Type gates**: route model readiness (step geometry + snapped-progress fields) pinned by tests. "Router pending" state covered by UI-SPEC + captures. Latency/subrequest budgets are not applicable to fixture world (documented as such, not faked).
### Privacy (carried + made specific)
- **D-12:** Fixture geometries are public-data routes between public places — no user coordinate ever enters a fixture. The build-time OSRM fetch happens on the maintainer's machine against fixed public points; the script documents this boundary. Runtime: origin stays in memory only (Phase 3 law); no route persists (envelope is types-only this phase).
### OpenCode's Discretion
- Fixture encoding format (encoded-polyline vs simplified GeoJSON) within the ≤200KB budget
- RoutingSource interface shape (async, backend-tagged results, tolerance metadata)
- Which Phase 4 corpus destinations become the 10–20 pairs; realistic origins list
- One-line-difference copy per alternative card (en/ms, clinical tone); "router pending" copy
- Chip/card visual details within the Rams system (UI-SPEC will bind exact geometry)
</decisions>
<specifics>
## Specific Ideas
- The route experience should feel like the mockup distilled: the orange line is THE accent on a grayscale world — nothing else competes.
- "Router pending" copy should be honest and calm: the app knows what routing is, states it isn't wired yet, offers search again. Same clinical register as the denied card.
- Fixture pairs double as the demo script: the 10–20 pairs are the routes a reviewer walks to see the product work.
</specifics>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope + decisions
- `.planning/ROADMAP.md` §Phase 5 — goal, success criteria **+ this file's SC3 amendment (planning applies it)**
- `.planning/REQUIREMENTS.md` §RTNG-01/02 (+ GUID-05/06 upstream obligations)
- `.planning/phases/04-destination-search-kl-gazetteer/04-CONTEXT.md` — SearchSource pattern to mirror; backend-ladder pattern; fork-model law (GPL-3.0)
- `.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md` — map boot, marker/follow controller, memory-only law, location-pin TTL precedent
### Codebase
- `web/src/map/` — MapLibre boot (style never re-set — route layers must be addLayer, not style mutation), marker, follow controller
- `web/src/search/resolve-destination.ts` — the preloaded destination this phase routes TO
- `search/` — the interface+backend+ladder pattern to mirror
- `web/scripts/capture.mjs` + mock profiles — the capture harness the route display must extend (`?dest=` profiles exist)
- `bot/src/i18n/strings.ts` pattern — copy discipline for card/difference strings
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `search/` workspace: interface + ladder + loader + corpus-gate patterns to mirror for `routing/`
- Phase 4 corpus gate (D-13/D-14) — the fixture-pair tolerance gate follows its shape
- Mock harness profiles (`?dest=`) — route-state capture profiles extend these
- BA build-artifact gates — the bundle-budget gate joins them
### Established Patterns
- npm workspaces + delegate scripts; TDD RED→GREEN; hand-rolled runners
- Interface + documented backend ladder (SearchSource → RoutingSource)
- Types-only frozen contracts (session envelope) with TTL-only storage law
- UI-SPEC gate applies this phase (UI hint: yes) — planning routes through it
### Integration Points
- Destination resolver → route destination input (Phase 4 hands off where Phase 5 begins)
- Route model step geometry + snapped-progress fields → Phase 6 guidance consumes
- `guidance_sessions` envelope types → Phase 6 bot service implements
</code_context>
<deferred>
## Deferred Ideas
- **WASM-router OSS project** — client-side routing in the webview (RoutingSource backend #2); static-hosted, privacy-maximal, fork-perfect; its own post-v1 project
- **Valhalla-on-VPS** — RoutingSource backend #3; docker-compose + Caddy + provision scripts as a documented path (Oracle Always Free candidate; full-MY extract chosen if provisioned)
- **Worker `/api/route` proxy** — only meaningful with a server backend
- **Real routing for arbitrary origins** — arrives with backend #2/#3; the "router pending" state is the honest interim
- Turn instructions/progress UI — Phase 6 (GUID-*)
- Toll-avoidance / costing knobs — v1.x
</deferred>
---
*Phase: 05-routing-route-display*
*Context gathered: 2026-10-24*
@@ -0,0 +1,62 @@
# Phase 5: Routing + Route Display - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions captured in CONTEXT.md — this log preserves the analysis.
**Date:** 2026-10-24
**Phase:** 05-routing-route-display
**Mode:** default (interactive)
## Areas Discussed
### Routing infrastructure — THE PIVOT (the phase's defining decision)
| Question | Options presented | Selection |
|---|---|---|
| VPS for Valhalla? | Oracle Always Free / paid VPS / research decides | **User redirected**: check the static-hosting/fork-model pivot from phase 3–4 discussions (lunr/Fuse/sql.js WASM, GitHub/CF hosting) |
| Extract | full MY / KL-only | Full Malaysia extract (mooted by pivot) |
| Serving | compose+Caddy+secret / bare systemd / Cloudflare Tunnel | "check planning docs for the pivot to forking model" |
### The reconciliation round (user informed of SC3 collision)
Explained: routing ≠ search — per-query shortest-path over multi-million-edge graphs; no Valhalla/OSRM WASM builds exist; static hosts execute nothing; Workers cap at 128MB/CPU-s; third-party APIs leak origin+destination (core-value violation). User then chose:
| Question | Options presented | Selection |
|---|---|---|
| Reconcile SC3 | fixture-now+WASM-later / VPS after all / third-party+exception / something else | **Fixture routes now, WASM track later** — SC3 amended, no VPS, no proxy |
### guidance_sessions envelope
| Question | Options presented | Selection |
|---|---|---|
| Scope at phase close | types-only contract / contract+KV live | Types-only contract |
| Snapshot placement | inline one-TTL / separate key | Inline snapshot, one TTL |
### Route UX surface
| Question | Options presented | Selection |
|---|---|---|
| Alternatives presentation | bottom card+chips / stacked cards | Bottom card + chips |
| Route line | orange casing+fit bounds / single line | Orange casing + fit bounds |
| Phase terminal surface | display-only / placeholder start button | Display-only terminal |
### Corpus + budgets (fixture-shaped)
| Question | Options presented | Selection |
|---|---|---|
| Fixture geometry source | build-time precompute via public OSRM (no user data) / hand-drawn | Precomputed pairs, build-time |
| Gates | bundle-size + type gates / + fake latency gate | Bundle + type gates |
## Corrections Made
User corrected my VPS framing twice — pointing back at their static-hosting/fork-model
pivot (stated during phase 4 planning discussions). First round produced the VPS question
again; second round produced the SQLite/sql.js question; the routing-area answer finalized
as full deferral. The CONTEXT records their intent as the architectural direction
(WASM-router track), not a rejection of fork values — the VPS remains a documented
backend with provision scripts, just not this phase.
## Auto-Resolved
Extract choice (full MY) and serving shape (compose+Caddy) were answered but became moot
under the pivot — recorded as part of the documented backend #3 path in deferred ideas.
## External Research
Not needed this phase per roadmap ("standard patterns"); the WASM-router feasibility
assessment (no Valhalla/OSRM WASM builds; DIY scope) was knowledge-based, verified
against the phase-4 research's registry checks.
@@ -0,0 +1,99 @@
# Phase 5: Routing + Route Display - Pattern Map
**Mapped:** 2026-10-24
**Files analyzed:** 24 new/modified surfaces (grouped into 16 rows below)
**Analogs found:** 15 / 16 rows matched (1 genuinely new: the polyline6 codec — pure algorithm, RESEARCH is the spec)
> Source files for this map: 05-CONTEXT.md (D-01..D-12), 05-RESEARCH.md, 05-UI-SPEC.md (binding).
> The controlling analogs are the **`search/` workspace (Phase 4)** — interface + ladder + loader + corpus gate — and the **Phase 3 shell surfaces** (`map/`, `ui/cards.ts`, `telegram/backbutton.ts`).
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `routing/` workspace pkg (`package.json`, `tsconfig.json`) | package config | — | `search/package.json` / `search/tsconfig.json` (Phase 4, shipped) | exact |
| `routing/src/types.ts` (RoutingSource + RouteResult + day-one model fields) | model (types) | — | `search/src/types.ts` | exact |
| `routing/src/polyline.ts` (polyline6 codec) | utility | transform (pure) | **none** — new algorithm; 05-RESEARCH Finding 1 is the spec (round-trip law makes it self-verifying) | none (algorithm) |
| `routing/src/fixture-source.ts` (backend #1) | service | request-response (in-memory match) | `search/src/source.ts` + `search/src/gazetteer.ts` (lazy LAW 1/2/3) | exact |
| `routing/src/index.ts` (public API) | package surface | — | `search/src/index.ts` (named exports, subpath-with-extension import law) | exact |
| `routing/test/runner.mjs` + `harness.mjs` + `*.test.mjs` | test | — | `search/test/*` (same harness shape) | exact |
| `routing/src/data/route-fixtures.json` (committed fixtures) | data | — | `search/src/data/kl-gazetteer.json` (committed dataset, ODbL header object) | exact |
| `routing/test/fixtures/golden-pairs.json` (pinned corpus) | data (test fixture) | — | `search/test/fixtures/golden-corpus.json` | exact |
| `routing/scripts/fetch-route-fixtures.mjs` | script (repo tool, network) | file-I/O + network | `search/scripts/overpass-topup.mjs` (UA mandatory, soft-exit, never a runtime dep) | exact |
| `routing/scripts/route-corpus.mjs` + `routing/test/corpus.test.mjs` | script + Nyquist gate | batch | `search/scripts/gazetteer-corpus.mjs` + `search/test/corpus.test.mjs` (one measurement, two reporters) | exact |
| `bot/src/kv/guidance-sessions.ts` (envelope TYPES ONLY) | model (types) | — | `bot/src/kv/sessions.ts` (SessionClaim "the entire persisted claim surface — a 5th property means drift") | role-match (types-only, no KV calls) |
| `web/src/route/route-layers.ts` (GeoJSON sources + 4 layers) | service (map painting) | addSource/addLayer/setData | `web/src/map/map.ts` `building-3d` block (addLayer inside `map.on("load")`, reuses style-owned values, never re-sets style) | exact |
| `web/src/route/route-markers.ts` (origin ring + destination pin + name chip) | view (DOM markers) | Marker DOM | `web/src/map/marker.ts` (createPositionMarker DOM-marker pattern, `.pos-*` chrome in shell.css) | exact |
| `web/src/route/route-card.ts` (route card + chips + pending card) | view (card builders) | DOM | `web/src/ui/cards.ts` (mountHintedCard) + `web/src/search/destination-card.ts` (createElement + textContent ONLY, strings-module copy) | exact |
| `web/src/route/route.ts` (controller: consult, selection swap, camera) | controller | event/state | `web/src/map/follow.ts` (constants + controller closure; `CAMERA_PADDING_PX`/`RECENTER_DURATION_MS` precedent) | role-match |
| `web/src/main.ts` (mod) + `web/src/telegram/backbutton.ts` (mod: two-state walk) | controller wiring | — | themselves (Phase 3/4; `overlayPresent` closure becomes the route-state predicate) | — |
| `web/src/shell.css` (mod: chip/card-body/marker-chrome rules) | style | — | itself (token-only law; machined-geometry exception list) | — |
| `web/src/i18n/strings.ts` (mod: 10 route keys) | config/i18n | — | itself (COPY1..COPY6 gate) | exact |
| `web/index.html` (mod: `?fixpair=` knob) | config (harness page) | — | itself (`?dest=` profile block, 04-04) | exact |
| `web/src/mock/route-profiles.ts` (harness-only pair-origin lookup) | test harness | — | `web/src/mock/telegram-mock.ts` (harness-only module, BA5-excluded) | exact |
| `web/scripts/capture.mjs` (mod: 5 route legs + ROUTE signal) | script | batch | itself (SIGNALS array + captureLeg) | exact |
| `web/test/build-artifact.test.mjs` (mod: BA9) | test (gate) | — | itself (BA1..BA8) | exact |
| `web/test/route-surface.test.mjs` (new structural suite) | test | — | `web/test/destination-preload.test.mjs` (DP-gate style) | exact |
## Pattern Assignments
### `routing/src/types.ts` (model, types-only)
**Analog:** `search/src/types.ts` — same header-contract comment style, same "the seam every backend implements" law, same empty-value-is-load-bearing semantics (`[]` → `null`).
```typescript
// search/src/types.ts:57-63 — the shape being mirrored:
export interface SearchSource {
readonly name: string;
search(q: SearchQuery): Promise<SearchHit[]>; // EMPTY array = honest refusal
byId(id: string): GazetteerEntry | null;
}
// becomes: route(req): Promise<RouteResult | null> — null = router pending (D-03)
```
### `routing/src/fixture-source.ts` (service)
**Analog:** `search/src/gazetteer.ts` LAW 1/2/3 verbatim — lazy memoized index (never module scope), static JSON import (`with { type: "json" }`), zero logging/storage. The matcher adds one pure function the gazetteer never needed: a haversine distance with a 200 m tolerance constant (05-RESEARCH Finding 3).
### `routing/scripts/fetch-route-fixtures.mjs` (repo tool)
**Analog:** `search/scripts/overpass-topup.mjs` — descriptive User-Agent mandatory, inter-request delay, per-item soft diagnostics, writes a committed JSON dataset, imported by NOTHING at runtime. The header contract states the D-12 boundary (fixed public coordinates, maintainer machine, no runtime dependency).
### `routing/test/corpus.test.mjs` (Nyquist gate)
**Analog:** `search/test/corpus.test.mjs` — "the suite re-runs the corpus runner's measurements IN-PROCESS (never spawns the script), so `npm run routing:test` fails whenever the corpus regresses." One measurement core exported from the script, two reporters.
### `web/src/route/route-layers.ts` (map painting)
**Analog:** `web/src/map/map.ts:43-67` — the `building-3d` block is the only existing `addLayer` precedent: inside `map.on("load")`, no `beforeId`, paint values copied from style-owned constants ("zero invented colors", DSGN-01). UI-SPEC extends it: 4 layers added AFTER the building-3d block, bottom→top `route-alt-casing` → `route-alt-line` → `route-casing` → `route-line`; sources added empty, filled via `setData` (data, not style — never a style re-set).
```typescript
// web/src/map/map.ts:43 — the exact insertion point (AFTER this block, same handler):
map.on("load", () => { /* building-3d … console.log("RAMS-BASEMAP-READY …") */ });
```
### `web/src/route/route-card.ts` (card builders)
**Analog:** `web/src/search/destination-card.ts` — createElement + textContent ONLY (an id/name-derived string never reaches innerHTML), composed from theme classes, every string from the strings module (COPY5). Chips add the `.toggle-option` focus precedent (3px orange bracket) and the `<button type="button">` law. **Per the UI-SPEC post-approval addendum: NO per-card padding overrides — compose from the existing `.card > *` padding rules (shell.css:74-80).**
### `web/src/route/route.ts` (controller)
**Analog:** `web/src/map/follow.ts` — named constants beside the existing ones (`ROUTE_CAMERA_PADDING = { top: 96, bottom: 320, left: 64, right: 64 }`, `ROUTE_FIT_DURATION_MS = 400`), a closure controller, no state outside memory (D-07).
### `bot/src/kv/guidance-sessions.ts` (envelope types)
**Analog:** `bot/src/kv/sessions.ts` `SessionClaim` — "the entire persisted claim surface — a 5th property means drift" — but TYPES-ONLY per D-06: no `KvLike`, no `.put(`, no wrangler namespace; the TTL exists as a documented constant (`GUIDANCE_SESSION_TTL_SECONDS = 7200`) awaiting its Phase 6 consumer. The grep gate (bot test) pins "types-only" mechanically.
### `web/src/telegram/backbutton.ts` (two-state walk)
**Analog:** itself — Phase 3's total-function wire (`overlayPresent() => false` → `close()`); the predicate becomes "route or pending state active" and the pushed-state click walks back via the SAME total function SEARCH AGAIN invokes (UI-SPEC §BackButton Contract). The handler and predicate ship as one change.
## Data Flow Notes
- **Origin never persists:** the request `{origin, destinationId}` is constructed in main.ts's closure from the live fix + the Phase 4 memory-only destination, passed by argument, and dropped with the frame (D-07/GEO-03 law — the route module takes no storage, keeps no module-scope origin).
- **Fixtures are public data:** `route-fixtures.json` enters the bundle by static import exactly like `kl-gazetteer.json` (build-time inlining, BA7-style parity gate idea carries to BA9's budget measurement).
- **Signals are state tokens:** `ARAHARAH-ROUTE state=drawn|pending` — never coordinates (D-15 hygiene, the `ARAHARAH-DEST state=` precedent).
---
*Pattern map: 2026-10-24 — pattern-mapper role run inline (no subagent spawn capability; sanctioned fallback).*
@@ -0,0 +1,50 @@
# Phase 5: Routing + Route Display — Research (light pass)
**Researched:** 2026-10-24 (light pass — the pivot removed Valhalla; per roadmap note "standard patterns, no research phase needed", this pass covers only the three open questions planning actually faces)
**Scope:** fixture encoding format · OSRM demo build-time script shape · RoutingSource interface analogs. Everything else is decided in 05-CONTEXT.md (D-01..D-12) and 05-UI-SPEC.md (binding).
---
## Finding 1 — Fixture encoding: encoded polyline6 VERBATIM (not GeoJSON)
**Decision:** store each route as the encoded-polyline string exactly as the OSRM demo returns it (`geometries=polyline6`), plus `distanceM`/`durationS`. A decoder lives in the routing workspace.
- **Verified live (2026-10-24):** `GET https://router.project-osrm.org/route/v1/driving/101.7117,3.1578;101.6869,3.1343?alternatives=2&overview=full&geometries=polyline6` → `code: Ok`, 2 routes, route 1 = 6907.9 m / 663.2 s with a **1200-char** geometry string; route 2 = 6572.8 m / 696 s / 1252 chars.
- **Size math vs the D-11 budget:** ~45 routes (15 pairs × up to 3) × ~1.2–2.5 KB ≈ **40–60 KB raw → ~12–20 KB gzipped**. The ≤ 200 KB gz budget leaves an order of magnitude of headroom. GeoJSON coordinate arrays for the same geometry measure ~10–20× larger for zero fidelity gain — rejected.
- **Verbatim storage is the lossless choice:** the string is what the demo server returned — no float rounding, no re-serialization. The decoder (standard Google encoded-polyline varint/delta algorithm, precision 1e6) is ~40 lines, pure, and round-trip testable (encode(decode(s)) === s per fixture — a free corpus assertion).
- **Snapped-progress support (D-07, GUID-05/06 upstream obligation):** fixtures do NOT precompute cumulative distances — the runtime derives `cumulativeDistanceM` once at decode/load (deterministic haversine walk, memoized). Keeps fixtures lean; the MODEL still carries the field from day one.
## Finding 2 — OSRM demo build-time script shape (verified shape, repo-tool precedent)
- **Endpoint:** `https://router.project-osrm.org/route/v1/driving/{lng},{lat};{lng},{lat}?alternatives=2&overview=full&geometries=polyline6` — `alternatives=2` returns up to 2 alternates (≤ 3 total routes; matches RTNG-02's "up to 3"), `overview=full` gives the complete geometry, `steps=false` (turn instructions are Phase 6; fixture `steps` stay typed-but-empty — never fake).
- **Response contract:** `code: "Ok"`, `routes[]` best-first (preserve order), each with `distance` (m), `duration` (s), `geometry` (polyline6 string). A `code !== "Ok"` or 0-route response is a script HARD FAILURE for that pair — never a silently missing pair.
- **Script posture — exact analog `search/scripts/overpass-topup.mjs` (Phase 4 D-06 "repo tools never runtime deps"):** runs on the MAINTAINER's machine (`npm run routing:fixtures`), never in CI, never at `vite build`, never at Worker request time. Mandatory descriptive User-Agent, polite delay between pair fetches, writes the committed `routing/src/data/route-fixtures.json`, soft-fails with a per-pair diagnostic. **D-12 boundary stated in the script header:** it hits FIXED PUBLIC coordinates (gazetteer landmark points) only; no user coordinate can ever enter a fixture; the app itself gains no network dependency.
- **Sustainability honesty:** the demo server is a free best-effort service. That is acceptable precisely because its output is COMMITTED — the runtime never touches it. A server policy change breaks the (rare) regeneration script, never the product.
## Finding 3 — RoutingSource interface (the SearchSource analog)
Mirror of `search/src/types.ts` (verbatim shape correspondence, D-01):
| SearchSource (Phase 4, shipped) | RoutingSource (this phase) |
|---|---|
| `readonly name: string` | `readonly name: string` — `"fixture"` now; `"wasm-router"` (#2) / `"valhalla"` (#3) documented, not shipped |
| `search(q): Promise<SearchHit[]>` | `route(req): Promise<RouteResult \| null>` |
| EMPTY array = honest refusal (D-13) | **`null` = honest "router pending" (D-03)** — never a blank map, never a fake route |
| `byId(id): GazetteerEntry \| null` (sync) | not needed this phase (the webview already holds the resolved `GazetteerEntry` from Phase 4) |
- `RouteResult` carries `options: RouteOption[]` — **index 0 is the best route** (OSRM returns best-first; order preserved), 1..3 entries. Each option: geometry (decoded points + derived cumulative distances), summary (distance/duration), and the day-one `steps` field (typed, empty in fixture world — D-10 defers instructions to Phase 6; an empty array is honest "not wired", a fabricated step list would not be).
- **Fixture matching law:** a pair serves a request when `destinationId` matches exactly AND the requested origin sits within a fixed public tolerance (200 m haversine) of the pair's pinned origin. Anything else → `null` (the pending state). The 200 m pad absorbs GPS jitter around a known landmark without ever fabricating a route for an unknown origin.
- **Lazy-index + three-environment laws carry over verbatim** from `search/src/gazetteer.ts` (LAW 1 lazy memoized load; LAW 2 static JSON import inlined by esbuild AND Vite, importable by bare Node 24 with `with { type: "json" }`; LAW 3 no logging/no storage — the D-15 analog: request coordinates never leave the caller).
## Validation Architecture
- **Framework:** existing hand-rolled runners; plan 05-01 adds the fifth workspace's identical pair (`routing/test/runner.mjs` + `routing/test/harness.mjs`, copied verbatim from `search/test/`). No new framework (Phase 4 precedent).
- **Primary acceptance instruments:** (1) the routing workspace suite (codec vectors, contract gates, lazy/no-logging/no-storage laws); (2) the golden-pair corpus gate (offline: every pair's geometry↔summary consistency recomputed by haversine + round-trip codec proof + alternatives floor) — the D-14 corpus-gate shape, adapted to fixtures; (3) the web suite extensions (route state machine, copy gate, BackButton walk); (4) BA9 bundle-budget gate over the production build; (5) the capture harness legs (`?fixpair=`) with the `ARAHARAH-ROUTE state=` token.
- **Offline law:** no test, suite, or gate ever touches a network. The OSRM demo is exercised ONLY by the maintainer script. This is what makes the corpus gate deterministic and the "fixture resolves cannot fail at runtime" claim (UI-SPEC Copywriting §Error state) true.
- **Blocking checkpoint:** none this phase — no real-device leg is required (route display is fixture-world; the Phase 3/4 device gates already proved map + deep-link on hardware). Capture legs + structural gates carry verification.
---
## RESEARCH COMPLETE
*Light pass as directed: three findings, folded into plans 05-01/05-02/05-04/05-05. No open questions remain.*
@@ -0,0 +1,97 @@
---
phase: 5
slug: routing-route-display
status: final
nyquist_compliant: true
wave_0_complete: false
created: 2026-10-24
updated: 2026-10-24
---
# Phase 5 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Existing hand-rolled harnesses (`bot/test/runner.mjs`, `web/test/runner.mjs`, `search/test/runner.mjs`); plan 05-01 adds the fifth workspace's identical pair (`routing/test/runner.mjs` + `routing/test/harness.mjs`, copied verbatim) — no new framework |
| **Config file** | None — suites are discovered by filename convention (`*.test.mjs`) per workspace |
| **Quick run command** | `npm run routing:test` / `npm test -w web` / `npm test -w bot` |
| **Full suite command** | `npm run routing:test && npm test -w web && npm test -w bot && npm test -w search && npm run audit -w theme` + typechecks + `npm run routing:corpus` |
| **Corpus gate command** | `npm run routing:corpus` (fixture-pair tolerance gate; also asserted in-process by `routing/test/corpus.test.mjs` so `npm run routing:test` fails on a pair regression) |
| **Estimated runtime** | ~20 seconds (hand-rolled runners are sub-second; the capture harness and the production-build BA9 gate are plan-scoped legs) |
---
## Sampling Rate
- **After every task commit:** Run the touched workspace's suite (`npm run routing:test`, or `npm test -w web` / `npm test -w bot`) plus that workspace's typecheck.
- **After every plan wave:** Run the full suite (`npm run routing:test && npm test -w web && npm test -w bot`) **plus** `npm run routing:corpus` — the golden-pair gate must hold at every wave merge, not just at the end.
- **Before `/gsd-verify-work`:** Full suite green + corpus gate green + BA9 bundle-budget gate green (production build).
- **Max feedback latency:** 20 seconds (excluding the plan-scoped capture harness and the production build the BA gate requires — per-plan legs, not per-commit legs).
---
## Per-task Verification Map
| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 05-01-T1 (workspace + contracts + route model) | 01 | 1 | RTNG-01, D-01/D-07 | T-05-01/06/07 | typed RoutingSource seam; day-one step-geometry + snapped-progress fields pinned by tests; no logging/storage | unit | `npm run routing:test` + `npm run routing:typecheck` | ❌ W0 | ⬜ pending |
| 05-01-T2 (polyline6 codec) | 01 | 1 | RTNG-01 | T-05-02 | pure codec; round-trip law; malformed-input refusal (never NaN points) | unit | `npm run routing:test` | ❌ W0 | ⬜ pending |
| 05-01-T3 (fixture backend + lazy loader + index) | 01 | 1 | RTNG-01, D-03/D-04/D-05 | T-05-03/04 | null = honest pending; 200 m tolerance law; lazy index; three-environment loader proof; empty-fixture schema committed | unit + bundle | `npm run routing:test` + esbuild bundle marker | ❌ W0 | ⬜ pending |
| 05-03-T1 (guidance_sessions envelope types + gates) | 03 | 1 | D-06/D-07 | T-05-08 | types-only: no KV namespace, no runtime writes, TTL constant 7200 | gates + typecheck | `npm test -w bot` + `npm run bot:typecheck` | ❌ W0 | ⬜ pending |
| 05-02-T1 (golden-pairs corpus + fetch script) | 02 | 2 | RTNG-01/02, D-02/D-12 | T-05-05 | public coords only; UA mandatory; per-pair hard-fail on non-Ok; 10–20 pairs committed | fixture-driven | `npm run routing:test` | ❌ W0 | ⬜ pending |
| 05-02-T2 (offline tolerance gate + corpus runner) | 02 | 2 | RTNG-01/02 | T-05-05/12 | geometry↔summary consistency offline; codec round-trip per fixture; alternatives floor; fixture size early-warning | corpus | `npm run routing:corpus` + `npm run routing:test` | ❌ W0 | ⬜ pending |
| 05-04-T1 (route layers + markers + camera) | 04 | 2 | RTNG-01, D-09 | T-05-09 | addLayer-after-building-3d ordering; zero style re-set; verbatim hex constants; pitch 35 preserved | unit + typecheck | `npm test -w web` + `npm run web:typecheck` | ❌ W0 | ⬜ pending |
| 05-04-T2 (route card + chips + pending card + strings) | 04 | 2 | RTNG-02, D-03/D-08/D-10 | T-05-10 | COPY5 strings-module law; no per-card padding overrides; chips = `<button type="button">`; no start-guidance affordance | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| 05-04-T3 (main.ts wiring + BackButton two-state walk) | 04 | 2 | RTNG-01/02, D-10 | T-05-10/11 | auto route consult after live+destination; no route request when destination unavailable; back always walks to root | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| 05-05-T1 (BA9 bundle-budget gate) | 05 | 3 | RTNG-01, D-11 | T-05-12 | fixture+route code ≤ 200 KB gz over production-shaped artifacts | build-artifact | `npm test -w web` (after `npm run build -w web`) | ❌ W0 | ⬜ pending |
| 05-05-T2 (route capture legs + ?fixpair knob + gallery) | 05 | 3 | RTNG-01/02 | T-05-14/15 | state-token signals only (no coordinates in console); pairId-only harness knob; harness module provably excluded; route drawn/alt/pending/dark legs non-blank | capture | `npm run web:capture` | ✅ extend | ⬜ pending |
| 05-05-T3 (no-`/api/route` + no-user-coordinate gates) | 05 | 3 | D-05/D-12 | T-05-16/05 | Worker gains no route proxy; fixtures carry zero user-coordinate risk surface | gates | `npm test -w bot` + `npm run routing:test` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
All new this phase (the repo has no routing infrastructure today). The owning tasks are marked; a task's `<verify>` may reference a suite created in the SAME task (RED-first per the repo's TDD convention).
- [ ] `routing/test/runner.mjs` + `routing/test/harness.mjs` — the fifth workspace's harness pair, copied verbatim from `search/test/` (05-01-T1)
- [ ] `routing/test/contract.test.mjs` — RoutingSource shape, day-one model fields, null-pending contract (05-01-T1)
- [ ] `routing/test/polyline.test.mjs` — codec vectors + round-trip + malformed-input refusal (05-01-T2)
- [ ] `routing/test/fixture-source.test.mjs` + `routing/test/loader-env.test.mjs` — matching law, tolerance boundary, lazy index, no-logging/no-storage, three-environment loader proof (05-01-T3)
- [ ] `routing/test/fixtures/golden-pairs.json` — the pinned 10–20 pair corpus with tolerances (05-02-T1)
- [ ] `routing/scripts/fetch-route-fixtures.mjs` + `routing/scripts/route-corpus.mjs` + `routing/test/corpus.test.mjs` — the maintainer tool + the offline tolerance gate (05-02)
- [ ] `bot/test/guidance-envelope.test.mjs` — types-only envelope gates (05-03-T1)
- [ ] `web/test/route-surface.test.mjs` — layer order/paint constants, card/chips structure, camera constants, BackButton walk (05-04)
- [ ] **Extend (not new):** `web/test/copy-gate.test.mjs` (COPY1 floor + route keys verbatim), `web/test/build-artifact.test.mjs` (BA9 bundle budget), `web/test/no-storage.test.mjs` (explicit `web/src/route/` case note), `bot/test/privacy.test.mjs` or new bot structural gate (no `/api/route`), `web/scripts/capture.mjs` + `web/index.html` (?fixpair legs)
*The corpus runner is the phase's primary acceptance instrument (D-02's tolerance gates) — it is both a script and an in-suite gate, so `npm run routing:test` fails whenever a committed pair regresses.*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Route card + chips render legibly on a real device | RTNG-01/02 | Visual truth on real Telegram clients (the automated legs prove structure + capture the 390×844 viewport) | Optional: open the Mini App with `?dest=suria-klcc` on device after deploy; confirm the orange route, card, and chips against `web/screenshots/shell-route-fixture.png` |
*One optional manual leg only — the fixture world removes the real-device blocker Phase 3/4 carried. The phase's "router pending on device" behavior is covered by the pending capture leg.*
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify (every task carries an automated verify)
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 20s (excluding plan-scoped capture/build gates)
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** planner-complete (2026-10-24) — statuses flip to ✅ during execution