29 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-routing-route-display | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>
@AGENTS.md @.planning/phases/05-routing-route-display/05-CONTEXT.md @.planning/phases/05-routing-route-display/05-RESEARCH.md @.planning/phases/05-routing-route-display/05-PATTERNS.md @.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-museumappears ONLY as an ORIGIN (pair 12) — it is NEVER a destinationId (gate RT4 pins this; plan 05-05's pending capture usesdest=national-museumas 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 withdistance(m),duration(s),geometry(string). - Per-pair HARD FAIL when
code !== "Ok"orroutes.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.mjsis 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.mjsexportsmeasureCorpus()precisely sosearch/test/corpus.test.mjscan 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
searchworkspace imports (import … from "../src/index.ts") work in.mjsscripts under Node 24 type-stripping (gazetteer-corpus.mjsdoes 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> |
{
"_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, thegazetteer-corpus.mjscross-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
originIdentry 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 headerUser-Agent: araharah-fixtures/1.0 (build-time fixture precompute; repo: araharah),1200 msdelay between pairs, ONE retry on network/5xx failure. - Per-pair hard fail on
code !== "Ok"orroutes.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.jsonpreserving 05-01's header fields (_license,_attribution,_notice,_source) withversion: 1,originToleranceM: 200, and the populatedpairs— 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).
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"
<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>
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).
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 whoseid === originIdsatisfies|entry.lat − origin.lat| <= 1e-4AND|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, finitedurationS > 0, non-emptygeometrystring. - RT4 probe exclusion — NO pair's
destinationIdequalspendingProbeDestinationId(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 / durationSwithin[1.0, 30]m/s (≈ 3.6–108 km/h — KL congestion floor to DUKE highway ceiling). - RT7 codec round-trip —
encodePolyline6(decodePolyline6(geometry)) === geometryfor every route (the 1e-6-grid law, verbatim storage proof). - RT8 licence header —
_license === "ODbL-1.0",_attributioncontainsOpenStreetMap,_noticecontainsODbL,_sourcecontainsrouter.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.
cd /home/C-Fu/araharah && npm run routing:corpus 2>&1 | tail -15 ; npm run routing:test 2>&1 | tail -15
<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>
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.
<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>