diff --git a/docs/ROUTING.md b/docs/ROUTING.md
new file mode 100644
index 0000000..c154977
--- /dev/null
+++ b/docs/ROUTING.md
@@ -0,0 +1,1585 @@
+
+# Routing — the deep dive
+
+Arah² routes today. This document explains **how**, at the level of data structures,
+algorithms, and measured numbers — and what the *committed but unbuilt* upgrade lever
+(ALT landmark preprocessing — ALT landmarks are graph vertices, not POIs; see §0.8)
+would change if it lands.
+
+Three questions, answered in order — after a short vocabulary primer (§0):
+
+1. **How does routing work today?** — the CSR road-graph binary, the bidirectional
+ typed-array A\* over it, snapping, turn classification, and the two-backend dispatch
+ between the fixture corpus and the live graph engine.
+2. **How is ALT supposed to work?** — the mechanics, precisely, and where it would slot
+ into the existing bidirectional A\*. **Not built.** It is a next-milestone item.
+3. **Pros and cons of every approach** — unidirectional A\*, bidirectional A\*, fixture
+ routes, ALT, and the graph-pruning experiment — with the numbers this repo actually
+ contains.
+
+Every claim names a real file and a real function. Where a claim is a plan rather than
+code, it says so.
+
+---
+
+## 0. Start here — the words this document uses
+
+Routing has a small vocabulary that is easy to nod along to and hard to actually picture.
+This section defines each term in plain language, using the real example from this
+codebase, so §1 onward can use the words without stopping to re-explain them.
+
+### 0.1 "Routing graph" — dots and arrows, not a drawing
+
+A **routing graph** is a stripped-down model of the road network: a set of **nodes**
+(points) and **edges** (one-way hops between two points), where each edge carries a
+**weight** — a number saying how expensive that hop is.
+
+In this repo:
+
+- A **node** is one distinct coordinate on an OSM way. The build script does not invent
+ intersections: `internNode` in `routing/scripts/build-road-graph.mjs` keys every
+ geometry point by its coordinate rounded to 1e-6° (`round6`) and returns the same id
+ for the same point. An intersection — a coordinate shared by two OSM ways — therefore
+ becomes **one node where several road pieces meet**; an ordinary bend is a node where
+ exactly two road pieces meet (four directed edges, two each way); the end of a dead-end
+ street is a node where only one road piece meets (two directed edges, unless the street
+ is oneway).
+- An **edge** is one consecutive pair of points along a way. For each pair the script
+ emits **two directed edges**, one each way:
+ `rawEdges.push({ from, to, seconds, flags: reverseOnly ? 1 : 0, nameId })` and its
+ mirror. So "677,068 directed edges" is roughly half that many physical road pieces,
+ each modelled in both directions.
+- The **weight** is `seconds` — `meters / (kmh / 3.6)` — *travel time*, not distance.
+ That is why the router minimizes **seconds**, and why the returned `distanceM` has to
+ be recomputed afterwards (§1.3, "Reconstruction and real costs").
+- The **oneway flag** is not a separate structure: it is bit 0 of `edgeFlags` on the
+ blocked mirror edge. That edge stays in the graph — so snapping can still see its
+ geometry and street name — but A\* never traverses it.
+
+A three-node excerpt, using the committed mini-graph's real speed classes
+(`routing/test/fixtures/build-mini-graph.mjs`: perimeter 60 km/h, interior 30 km/h, and
+exactly one oneway pair, grid ids 2→3):
+
+```mermaid
+flowchart LR
+ classDef node fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
+ classDef interior fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
+
+ A["Node A — grid id 2 (an OSM geometry vertex: a perimeter corner)"]:::node
+ B["Node B — grid id 3 (an OSM geometry vertex: the next perimeter corner)"]:::node
+ C["Node C — grid id 8 (an OSM geometry vertex: an interior grid point)"]:::interior
+
+ A -->|"edge A→B · ≈3.3 s Jalan Persiaran Mini · 60 km/h"| B
+ B -.->|"edge B→A is BLOCKED edgeFlags bit 0 · oneway mirror"| A
+ B -->|"edge B→C · ≈6.7 s Jalan Mini 2 · 30 km/h"| C
+ C -->|"edge C→B · ≈6.7 s the same street, other direction"| B
+
+ linkStyle 1 stroke:#ED3F1C,stroke-width:3px
+```
+
+Two misconceptions this picture is meant to kill: a node is **not** a city or a category
+of junction — it is just a coordinate; and an edge is **not** a road — it is one directed
+hop between two adjacent coordinates, so a long road is many edges.
+
+### 0.2 "CSR" — the graph stored as flat arrays
+
+**CSR** stands for **Compressed Sparse Row**. The idea is to stop storing one JavaScript
+object per node and instead store a handful of parallel arrays plus one index array.
+
+Imagine 311k nodes. The obvious JS shape is
+`[{ lat, lng, edges: [...] }, ...]` — 311k objects plus 677k edge objects, every one of
+them a heap allocation and a pointer chase. CSR instead keeps:
+
+| Array | What it holds |
+| --- | --- |
+| `lat[]`, `lng[]` | one coordinate pair per node |
+| `edgeStart[]` | `n+1` numbers; node *i*'s edges are the slice `edgeStart[i] .. edgeStart[i+1]` |
+| `edgeTarget[]` | for each edge, the node it arrives at |
+| `edgeSeconds[]` | the edge weight (travel time) |
+| `edgeFlags[]` | bit 0 = the oneway mirror |
+| `edgeNameId[]` | index into the street-name pool |
+
+Walking node 42's neighbours is two reads and a short loop:
+
+```ts
+for (let e = graph.edgeStart[42]; e < graph.edgeStart[43]; e++) {
+ const neighbour = graph.edgeTarget[e];
+ const costSeconds = graph.edgeSeconds[e];
+}
+```
+
+No object dereference, no per-node array, no Map. "Compressed" means the full *n × n*
+adjacency matrix is never stored; "sparse row" refers to the row-pointer array
+(`edgeStart`) that finds each node's slice. The full binary layout in §1.2 is just this
+idea written down byte for byte: `Offsets` is `edgeStart`, `Edges` is
+`edgeTarget` + `edgeFlags`, `Weights` is `edgeSeconds`.
+
+**Why it avoids allocation.** `decodeGraph` in `routing/src/graph.ts` creates typed-array
+**views over the same `ArrayBuffer`** — `new Float64Array(buffer, lay.lat, nodeCount)` —
+not copies and not per-node objects (D-01). The A\* hot loop then allocates nothing per
+node: its per-query state is a fixed set of O(n) typed arrays created once at the top of
+`routeAstar` (`df`, `db`, `fPrev`, `bNext`, the two closed sets, the two heuristic caches,
+`pf`). Under a 10 ms CPU ceiling and a 128 MB isolate, 311k objects per request is not an
+option; the flat arrays are also why the hot loop has no GC pauses. `astar.test.mjs` A8
+pins the discipline structurally: it strips comments from `astar.ts` and fails if
+`new Map(` appears.
+
+### 0.3 "A\*" — search with a guess
+
+Plain Dijkstra explores outward in every direction until it happens to reach the goal.
+**A\*** adds a guess `h(v)` of the time still needed from `v` to the goal, and always
+expands the node with the smallest
+
+```
+f(v) = g(v) + h(v) g = seconds already spent, h = seconds still guessed
+```
+
+If the guess never overshoots the truth (the heuristic is **admissible**), the route A\*
+returns is still optimal; the better the guess, the fewer nodes it has to expand.
+
+In this codebase the guess is
+
+```
+h_f(v) = hav(v, goal) / maxSpeedMps (routing/src/astar.ts)
+```
+
+`maxSpeedMps` is the fastest class in the entire graph (`graph.maxSpeedKmh`, written at
+build time). That makes the guess **admissible** — a straight line is the shortest
+possible path, and dividing by the fastest possible speed gives the shortest possible
+time — but **loose**: it assumes a 15 km trip could be driven entirely at 100 km/h. On a
+local street the real time is longer, so the guess understates the remaining cost and A\*
+explores many nodes it did not need to. **That looseness is the root cause ALT attacks**
+(§2.1).
+
+Both directional heuristics are scaled into seconds: `haversineM` returns metres, divided
+by metres-per-second.
+
+### 0.4 "Bidirectional A\*" — two searches that meet in the middle
+
+Run the same search from the origin **forward** and from the goal **backward** over
+**in-edges** (that is why `reverseFor` exists — a source-grouped CSR cannot list a node's
+in-edges). Each side keeps its own heap and closed set. When an edge connects a
+forward-reached node to a backward-reached node, that is a candidate meeting with total
+cost `d_f(v) + d_b(v)`; keep the best one, `mu`. Stop when the two frontiers' smallest
+keys sum to at least `mu` — no unexpanded node can then beat the best meeting.
+
+```mermaid
+flowchart LR
+ classDef origin fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
+ classDef goal fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
+ classDef frontier fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
+ classDef meet fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
+
+ O["origin node (a GPS fix, after snapping)"]:::origin
+ F1["forward frontier ring 1"]:::frontier
+ F2["forward frontier ring 2"]:::frontier
+ M["meeting node (best mu seen so far)"]:::meet
+ B2["backward frontier ring 2"]:::frontier
+ B1["backward frontier ring 1"]:::frontier
+ G["goal node (a gazetteer POI, after snapping)"]:::goal
+
+ O --> F1 --> F2 --> M
+ G --> B1 --> B2 --> M
+ F2 -.->|"stop when top_f + top_b ≥ mu"| B2
+```
+
+**Why this is meant to halve the work.** A one-sided search must grow to radius `2r` to
+touch a goal `2r` away; two searches of radius `r` cover the same corridor with roughly a
+quarter of the area each, so total work is about half. That is the geometric intuition.
+In *this* implementation the measured p95 did **not** improve, because the potential split
+(§0.5) halves each side's heuristic steering at the same time. §3.4 carries the measured
+verdict; the correctness proof is unaffected either way.
+
+### 0.5 "Potential" — the word means two different things here
+
+This is a place where a reader can get lost, so separate the two senses once:
+
+**(a) The Ikeda potential used today (§1.3).** With the directional heuristics `h_f` and
+`h_b` above, the search uses
+
+```
+p_f(v) = (h_f(v) − h_b(v)) / 2 p_b(v) = −p_f(v)
+```
+
+and relaxes an edge with `c + p(u) − p(v)` instead of `c`. Read the formula as **"forward
+distance-to-goal minus backward distance-to-origin, halved"**. It is a **reweighting**, not
+a distance and not a lower bound: it shifts every node's key by a node-dependent constant
+so the two frontiers are measured on a comparable scale. The phrase "opposite potentials"
+is literal — `p_b` is the negative of `p_f`. A useful intuition: in a symmetric graph
+`h_f + h_b ≈` the whole route length, so `p_f ≈ h_f − (route length)/2`; the potential
+recentres the forward guess on the halfway point. The reweighted edge costs stay
+non-negative, so optimality survives, and the stop rule `top_f + top_b ≥ mu` becomes
+valid. The code computes it lazily and caches it in `hfCache` / `hbCache` / `pf`
+(`potential(v)` in `routing/src/astar.ts`).
+
+**(b) The ALT potential proposed in §2.** There, "potential" means a **lower bound on the
+remaining time**, `h_ALT(v)`, built from landmark distances. It *replaces* the haversine
+`h_f` / `h_b` that feed (a); the Ikeda reweighting then still sits on top. So §2's `h_f`
+is an input to §1.3's `p_f`, not a competitor.
+
+### 0.6 "Snapping" — GPS point → graph node
+
+A GPS fix is a coordinate, not a node id. `snapToGraph(graph, point)` answers "which graph
+node is this fix on?" in three moves (the exact authority is §1.4):
+
+1. Reject fixes outside the graph bbox plus 0.01° — an honest `null`.
+2. Look only in the fix's ~0.002° grid cell and its eight neighbours (the 3×3
+ neighbourhood).
+3. For every road segment touching those cells, project the fix onto the segment in
+ cos-scaled degree space and keep the closest; return the **nearer endpoint**
+ (`t <= 0.5 ? u : v`) plus that segment's street name.
+
+Every route query starts and ends with a snap; a snap miss is a designed `null` /
+router-pending answer, never a made-up node. Snapping deliberately ignores the oneway
+flag — geometry is directionless; only A\* enforces direction.
+
+### 0.7 Turn classification and step derivation
+
+`classifyTurn(prevBearingDeg, nextBearingDeg)` in `routing/src/astar.ts` is the single
+authority for "what is a turn". It compares the compass bearing of the segment you arrived
+on with the bearing of the segment you leave on. Bearings increase clockwise (N=0, E=90),
+so a **negative** delta is a **left** turn. Thresholds: ≤25° straight, ≤60° slight
+left/right, ≥150° U-turn, otherwise left/right. The pinned example:
+`classifyTurn(0, 40) === "slight-right"`.
+
+`deriveSteps(graph, nodes)` turns a node path into human steps. It walks consecutive
+pairs, computes each bearing, classifies the turn, and starts a new step whenever the turn
+is **not** `"straight"` **or** the street name changes. The first step is always
+`"depart"`; a final `"arrive"` step anchors the destination. Each step carries `kind`,
+`name` (omitted when the edge is unnamed), `distanceM`, and polyline6 `geometry`. It
+throws on a non-traversable pair — a malformed path is a caller bug, not a route miss.
+
+Fixture routes have **empty** step arrays (D-10) — honest "not wired"; the live engine
+populates them.
+
+### 0.8 The two different things called "landmark"
+
+> **Read this before §2.** This repository uses the English word **landmark** for two
+> unrelated objects. They share a name and nothing else.
+
+| | **Search / gazetteer landmark** | **ALT landmark** |
+| --- | --- | --- |
+| **What it is** | A Point of Interest a user searches for and drives to | A chosen **vertex of the routing graph**, used only as a distance reference |
+| **Real examples** | `suria-klcc` ("Suria KLCC", aliases "KLCC", "KLCC Park"), `national-museum`, `kl-sentral` — 30 entries in `search/src/data/kl-gazetteer.json` | an integer vertex id with no name, address, or POI record (illustrative: none has been chosen yet — no ALT code exists) |
+| **Where it lives** | `search/src/data/kl-gazetteer.json`; typed by `GazetteerEntry` in `search/src/types.ts` | would live in the landmark table of a **layout-v2 graph** — **nothing exists yet** |
+| **Who uses it** | search (`getEntries` / `getEntryById`), fixture corpus endpoints, `/api/route`'s `destinationId` lookup, snapping targets | only a future A\* speedup |
+| **How it is chosen** | curated by hand, with local aliases | algorithmically, by a farthest-point sweep over graph vertices (§2.2) |
+| **Shown to users?** | yes — it is the product's destination | never |
+
+**Which is which, wherever the doc says "landmark":**
+
+- Sentences about search, the gazetteer, fixture pairs, POIs, snapping, or "all
+ landmarks in one strongly-connected component" mean **gazetteer landmarks**.
+- Sentences about ALT, preprocessing, the 16–32 reference vertices, or a landmark table
+ mean **ALT landmarks**.
+- §2 uses "landmark" exclusively in the ALT sense; §1.6 and the ladder history use it
+ exclusively in the POI sense.
+
+**Why this matters for the graph-pruning story.** When §1.9 and §3.5 say the pruned graph
+"severed the fine mesh tying landmarks to the arterials", that means **gazetteer (POI)
+landmarks** — the fixture endpoints such as KL Tower, KL Sentral, and Suria KLCC. Those
+POIs are reached by snapping to a nearby minor street; dropping `tertiary` and
+`residential` removed the streets that connect those POI coordinates to the arterials, so
+the pairs became unreachable. It has nothing to do with ALT landmarks: no ALT code existed
+during that experiment and none exists now.
+
+Conversely, an ALT landmark is just a vertex — but because the farthest-apart vertices in
+travel-time terms tend to sit on long arterial or motorway chains, an ALT landmark will
+*often look like* an arterial intersection. Looking like one is a consequence of the
+selection, not its definition, and it is still not a POI. An ALT landmark at an arterial
+junction is not "KLCC"; it is a graph vertex that happens to be far from the other
+landmarks.
+
+---
+
+## 1. How routing works today
+
+### 1.1 The shape of the system
+
+Two routing backends answer the same contract (`RoutingSource` in
+`routing/src/types.ts`), and the client composes them into a ladder
+(`web/src/main.ts:431-436`):
+
+```ts
+const fixtureSource = createFixtureRoutingSource();
+const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() });
+const routingSource: RoutingSource = {
+ name: "ladder",
+ route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)),
+};
+```
+
+- **Backend #1 — `"fixture"`** (`routing/src/fixture-source.ts`): a build-time-precomputed
+ corpus of 15 golden KL pairs, served from the bundle. It matches on
+ `destinationId` **and** a 200 m origin tolerance.
+- **Backend #2 — `"engine-a"`** (`routing/src/http-source.ts`): a Bearer-gated
+ `POST /api/route` to the Worker, which runs the real router over the R2-hosted road
+ graph.
+
+The `??` is the load-bearing operator: **engine-a first, fixture as the fallback.** A
+null from engine-a (any miss class) falls through to the fixture corpus; a null from
+both is the honest "router pending" state.
+
+The ladder is **documented, not switchable** — no env read, no config read, no dynamic
+registry. The import site constructs the backends in code (T-05-04). The ladder's shape
+mirrors `SearchSource`'s (`docs/CORE-CONCEPTS.md` §2).
+
+**Where the dispatch actually lives.** The fixture-vs-graph-engine choice is **client-side**
+(`web/src/main.ts:431-436`). `bot/src/routing/` contains only the graph engine's server
+arm — `handleRoute` in `worker-router.ts` — and has no fixture backend at all. The diagram
+below is therefore split the same way the code is: orange = the Mini App's ladder, beige =
+the Worker, red = the R2 data plane, olive = the in-bundle fallback.
+
+```mermaid
+flowchart TD
+ classDef client fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
+ classDef server fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
+ classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
+ classDef fallback fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
+ classDef pending fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
+
+ CALL["web/src/main.ts route request"]:::client
+ LADDER["routingSource: 'ladder' httpSource.route() ?? fixtureSource.route()"]:::client
+ HTTP["POST /api/route Bearer session token"]:::client
+ WR["bot/src/routing/worker-router.ts handleRoute"]:::server
+ GRAPH[("R2 ROAD_GRAPH kl-roadgraph.v1.bin")]:::store
+ DEST["getEntryById(destinationId) gazetteer POI lookup"]:::server
+ SNAP["snapToGraph origin + destination"]:::server
+ ASTAR["routeAstar bidirectional A*"]:::server
+ RESULT["200 RouteResult backend: engine-a"]:::client
+ NULL["200 null (any miss class)"]:::pending
+ FIX["routing/src/fixture-source.ts 15 golden pairs · 200 m law"]:::fallback
+ PENDING["router-pending state (honest degradation)"]:::pending
+
+ CALL --> LADDER
+ LADDER -->|"1st: graph engine"| HTTP
+ HTTP --> WR
+ WR --> GRAPH
+ WR --> DEST
+ DEST --> SNAP --> ASTAR --> RESULT
+ RESULT --> CALL
+ WR -->|"miss"| NULL
+ NULL -->|"?? falls through"| FIX
+ FIX -->|"pair matches"| RESULT
+ FIX -->|"no match"| PENDING
+```
+
+Every arrow out of `handleRoute` that is not a `RouteResult` is the same `200 null` — the
+null contract is the dispatch signal, not an error (§1.7).
+
+### 1.2 The data plane: the CSR road-graph binary
+
+Engine A routes over a **CSR (Compressed Sparse Row) binary** — an adjacency structure
+where each node's outgoing edges are stored contiguously. The codec is
+`routing/src/graph.ts`: `encodeGraph` (build-time) and `decodeGraph` (runtime) are
+inverses, and the binary layout has exactly one authority — that file.
+
+**The pinned layout** (little-endian, documented at `routing/src/graph.ts:9-21`):
+
+| Region | Contents |
+| --- | --- |
+| Header (64 B) | magic `0x41524731` (`"ARG1"`), version `1`, `nodeCount`, `edgeCount`, `nameCount`, `namePoolLen`, bbox (4×f64), `maxSpeedKmh` (f32), 4 pad bytes |
+| Nodes | `f64 lat[n]` ++ `f64 lng[n]` |
+| Offsets | `u32 edgeStart[n+1]` — CSR row pointers; node *i*'s edges are `edgeStart[i]..edgeStart[i+1]` |
+| Edges | `u32 edgeTarget[e]` ++ `u8 edgeFlags[e]` |
+| Weights | `f32 edgeSeconds[e]` (4-byte aligned) |
+| Names | `u32 edgeNameId[e]` ++ `u32 nameOffset[m+1]` ++ UTF-8 `namePool` |
+
+The 60→64 byte header padding is not cosmetic: the f64 node arrays must be **8-byte
+aligned** for zero-copy `Float64Array` views, and an unaligned `Float64Array` throws.
+`decodeGraph` returns **typed-array views over the caller's `ArrayBuffer`** — zero
+per-node JS objects, which is the whole point (D-01).
+
+**Fail-honest decode (T-06-03).** `decodeGraph` validates magic, version, and the
+**exact byte size implied by the header counts**, and returns `null` on any mismatch.
+It never throws and never returns a half-built graph. The size check runs **before any
+view allocation** (T-06-03b), so a hostile header with huge counts rejects on
+arithmetic, not on allocation. The Worker turns that `null` into the router-pending
+state.
+
+**The oneway law (`edgeFlags` bit 0 = "oneway-reverse").** The build emits *both*
+directions of every OSM way. The blocked mirror direction stays in the CSR with bit 0
+set. This is deliberate: the edge still carries its **segment geometry and street
+name**, so the snapper can find it, but A\* never traverses it. Snapping ignores
+direction; A\* enforces it. See §1.4.
+
+**Build pipeline** — `routing/scripts/build-road-graph.mjs`:
+
+1. The bbox is **computed at run start** from the committed corpus (gazetteer
+ extremes + golden-pair coordinates ± 0.05°). It tunes itself when the corpus grows;
+ it is never hand-edited.
+2. **One** Overpass query fetches every way whose `highway` tag is in
+ `ACTIVE_CLASSES` — 13 classes: `motorway`, `trunk`, `primary`, `secondary`,
+ `tertiary`, `residential`, `unclassified`, `living_street`, plus the five `*_link`
+ ramp classes. `footway`/`cycleway`/`service`/`path` are deliberately absent (a
+ walking path must never enter a road A\*).
+3. Ways → nodes (deduplicated by coordinate rounded to 1e-6°, ≈ 0.11 m) → directed
+ edges with oneway flags, speed-derived seconds, and street-name pool ids.
+4. `encodeGraph` produces the pinned binary. **Collect-then-write**: the file is
+ written only after the encode and the stats gate both pass — any failure exits 1
+ with the previous binary untouched.
+
+The `*_link` classes are **load-bearing** (measured 2026-10-24): without them the graph
+**fragments** — 7.7% of nodes sat in severed motorway islands and golden corpus pairs
+resolved "unreachable". This was a real bug caught by the CPU measurement, fixed in
+commit `ea9584d`.
+
+**The envelope gate (D-01):** a binary over 80 MB is refused; one at or under 1 MB is
+refused ("not a real road graph"); 30–80 MB is the expected band, with a smaller result
+printing an advisory warning. The binary is a **build artifact, gitignored**
+(`routing/data/.gitignore` → `*.bin`) — ODbL-derived data never enters the repo.
+Forks build and upload their own (`docs/ROAD-GRAPH.md`).
+
+### 1.3 The A\* core: `routing/src/astar.ts`
+
+`routeAstar(graph, originNode, goalNode)` returns `{ nodes, seconds, distanceM }` or
+`null` for an unreachable pair.
+
+**It is bidirectional** — a BH (Bidirectional Heuristic) search with Ikeda potentials.
+The module header states why: the free-plan **10 ms CPU budget** is the design
+constraint, and the measured unidirectional p95 on the real KL graph was 3–5× over it.
+
+#### The heap
+
+A `NodeHeap` over two preallocated typed arrays: `Float64Array keys` and
+`Uint32Array nodes`, grown by doubling. `push` / `pop` / `peekKey`. `peekKey` returns
+`Infinity` on an empty heap — the BH stop rule reads it. Deletion is **lazy**: stale
+entries are skipped by the closed-set check in `routeAstar`.
+
+#### The hot loop is allocation-free
+
+No `Map`, no per-node objects. `routing/test/astar.test.mjs` A8 asserts this
+structurally: it strips comments from `astar.ts` and fails if `new Map(` appears.
+
+State per search:
+
+| Array | Type | Meaning |
+| --- | --- | --- |
+| `df`, `db` | `Float64Array(n)` | reduced distances, forward and backward |
+| `fPrev`, `bNext` | `Int32Array(n)` | predecessor / successor on the two chains |
+| `fClosed`, `bClosed` | `Uint8Array(n)` | closed sets |
+| `hfCache`, `hbCache` | `Float64Array(n)` | lazily-computed heuristic caches (NaN = not yet) |
+| `pf` | `Float64Array(n)` | the potential, `p_f(v)` |
+
+#### The BH construction (Ikeda et al.)
+
+With directional heuristics
+
+```
+h_f(v) = hav(v, goal) / maxSpeedMps
+h_b(v) = hav(v, origin) / maxSpeedMps
+```
+
+(each consistent on its own search graph — the speed divisor comes from the graph
+header's `maxSpeedKmh`), and the **opposite potentials**
+
+```
+p_f(v) = (h_f(v) − h_b(v)) / 2 p_b(v) = −p_f(v)
+```
+
+the reductions
+
+```
+forward, edge u→v: c + p_f(u) − p_f(v)
+backward, in-edge v→u of settled u: c + p_b(u) − p_b(v)
+```
+
+are **non-negative** (each feasibility is the sum of the two consistencies, each ≤ c),
+and the meet keys satisfy
+
+```
+d_f(v) + d_b(v) = real(o→g through v) − p_f(o) + p_f(g)
+```
+
+— a **v-independent constant**. Therefore stopping when `top_f + top_b ≥ mu` (the best
+meeting cost seen) settles the goal **provably optimally**, at roughly half the
+explored radius per side.
+
+#### The stop rule and the alternation knob
+
+```ts
+if (fHeap.peekKey() + bHeap.peekKey() >= mu) break; // THE BH STOP RULE
+const expandForward = fHeap.size <= bHeap.size; // BALANCED ALTERNATION
+```
+
+The stop rule carries optimality. The alternation is a **work-balancing knob only**.
+This is not a stylistic claim — it was a bug: the first implementation compared raw
+tops (`top_f <= top_b`), which starved the backward side (19,501 forward settles to 3
+backward) and landed **4× slower than unidirectional**. Comparing **heap sizes** keeps
+both rings growing. The fix is recorded in
+`.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md` §2.
+
+#### The reverse CSR
+
+The backward search needs **in-edges**. A source-grouped CSR cannot yield them without
+a reverse index, and reading out-edges as in-edges **fabricates phantom arcs** — the
+06-04 bug where a 6.66 s "path" was found against Dijkstra's 26.67 s.
+
+`reverseFor(graph)` builds the reverse adjacency **once per `RoadGraph`** and memoizes
+it in a module-scope `WeakMap` (the same LAW 1 lazy-memoize pattern as the snap grid).
+It holds `rowStart` (u32), `targets` (the in-edge's source node), and `edgeIds` (the
+edge's index in the forward CSR, for the weight and flag lookup). Build cost is
+O(V+E) per isolate, paid by the first route and amortized across the isolate's
+requests. **The pinned binary layout is untouched** — the reverse index is a derived
+in-memory structure.
+
+#### Reconstruction and real costs
+
+The path is `origin →(fPrev)→ meetFrom −[bridge]→ meetTo →(bNext)→ goal`. Then the
+**real** costs are recomputed along the node path — `haversineM` for distance,
+`graph.edgeSeconds[edge]` for time — so no reduced-cost arithmetic leaks into the
+returned `seconds`. `distanceM` is summed **post-hoc** over the node path, which is the
+plan's law (the graph stores travel *time*, not distance).
+
+#### The reverse edge lookup
+
+`findTraversableEdge(graph, u, v)` scans node *u*'s out-edges for one whose target is
+*v* **and** whose flag bit 0 is clear. It returns `-1` if none. This is what makes
+`deriveSteps` throw on a malformed path — a caller bug is **loud**, unlike a route miss
+which stays `null`.
+
+### 1.4 Snapping: `routing/src/snap.ts`
+
+`engine A owns snapping — ONE authority for road geometry, so the client and the router
+can never drift.` The **server** authority: a reroute origin lands here. (Phase 6's
+client-side guidance walks the selected route polyline instead and never calls this
+module.)
+
+`snapToGraph(graph, point)` returns `{ node, distanceM, nameId }` or `null`.
+
+**The grid.** A coarse grid of ~0.002° cells, built **once per graph** and memoized in
+a module-scope `WeakMap`. A fix touches only the **3×3 cell
+neighborhood** — O(local), never O(nodes). (A global nearest-node scan is the recorded
+anti-pattern.)
+
+**The projection.** Point-to-**segment** projection, not point-to-node, in **cos-scaled
+degree space** (`lng × cos(lat)`). Pitfall 8's law: projection math scales lng by
+cos(lat), never raw degree deltas. The parameter `t` is clamped to [0,1]; the snapped
+node is the **closer endpoint** of the best segment (`t <= 0.5 ? u : v`). The reported
+`distanceM` is **haversineM** — the only distance authority that leaves the module
+(squared-degree is used for ranking only).
+
+**The bbox miss.** A fix outside the graph bbox plus `SNAP_BBOX_MARGIN_DEG` (0.01°)
+resolves `null` — the designed router-pending signal. Never a fabricated node, never a
+throw.
+
+**Direction plays no part.** A oneway segment's flagged reverse edge still carries
+segment geometry and its street name, so fixes near a oneway road snap to its endpoints
+exactly like two-way traffic. **A\* enforces the direction law; snapping ignores it.**
+
+**Documented limitation.** Candidate segments come from the 3×3 neighborhood of the
+*fix's* cell — a segment both of whose endpoints sit more than one cell (~0.002°) away
+is not a candidate. KL-metro build data densifies at intersections, well inside this
+window; a miss degrades to `null`, never a wrong snap.
+
+The `nameId` field is an **additive** field on `SnapResult` — edges carry names, nodes
+don't — and it is what Phase 7's cron street-hop consumes.
+
+### 1.5 Turn classification and step derivation
+
+Both live in `routing/src/astar.ts`.
+
+#### `classifyTurn(prevBearingDeg, nextBearingDeg)` — the ONE authority
+
+The convention: **compass bearings increase clockwise (N=0, E=90), so a NEGATIVE
+bearing delta is a LEFT turn.** The delta normalizes to (−180, 180].
+
+| Threshold | Constant | Result |
+| --- | --- | --- |
+| \|delta\| ≤ 25° | `TURN_STRAIGHT_MAX_DEG` | `straight` |
+| \|delta\| ≤ 60° | `TURN_SLIGHT_MAX_DEG` | `slight-left` / `slight-right` |
+| \|delta\| ≥ 150° | `TURN_UTURN_MIN_DEG` | `uturn` |
+| otherwise | — | `left` / `right` |
+
+A note on the convention: the original plan text contained
+`classifyTurn(0, 40) → slight-left`, which contradicts its own `(0,−90) → left` and
+`(0,90) → right` cases. The compass-consistent reading is what shipped and what
+`astar.test.mjs` A6 pins: `classifyTurn(0, 40) === "slight-right"`.
+
+`classifyTurn` is exported from the package index because **plan 07's cron projection
+imports this exact classifier** — one authority for "what is a turn".
+
+#### `deriveSteps(graph, nodes)` — node path → maneuver steps
+
+Walks the node path, computing an initial compass bearing for each consecutive pair,
+classifying each turn, and **splitting the path into steps** where:
+
+- the bearing leaves `"straight"`, **or**
+- the street name changes.
+
+Each step carries `kind`, `distanceM` (haversine over its segments), `geometry`
+(polyline6 of its points), and `name` (omitted when the edge is unnamed —
+`UNNAMED_NAME_ID`). The first step is always `"depart"`; a terminal `"arrive"` step
+anchors the goal node (its geometry is the single goal point).
+
+**It throws** on a non-traversable consecutive pair — a caller bug is loud, unlike a
+route miss which stays `null`.
+
+Fixture-world steps are **always empty arrays** (D-10): an empty array is honest
+"not wired"; a fabricated step list would not be. Engine A populates them.
+
+### 1.6 Backend #1: the fixture source and its 200 m matching law
+
+`routing/src/fixture-source.ts` — `createFixtureRoutingSource(data?)`.
+
+The dataset (`routing/src/data/route-fixtures.json`) enters by **static import**
+(LAW 2 — esbuild and Vite inline it at build time; bare Node 24 reads it via
+type-stripping; a runtime retrieval would hard-fail in a Worker, which has no `fs`).
+The index (Maps, decoded geometry) is built **lazily on first use** and memoized
+(LAW 1 — Workers' 1 s startup limit and the webview's first paint both pay for eager
+work).
+
+**The matching law (D-02/D-03).** A pair may serve a request only when:
+
+1. the request's `destinationId` **equals** the pair's `destinationId`, **and**
+2. the request origin sits within **`ORIGIN_TOLERANCE_M` = 200 m** (haversine) of the
+ pair's pinned **public** origin.
+
+The 200 m pad absorbs GPS jitter around a known gazetteer (POI) landmark. It **never
+fabricates a route for an unknown origin**. Anything unmatched resolves `null` — the
+honest router-pending state, never a nearest-match.
+
+The corpus is 15 golden pairs (23 routes) — every origin and destination is a
+**gazetteer (POI) landmark** (`suria-klcc`, `kl-sentral`, …), *not* an ALT landmark —
+the two senses are distinguished in §0.8. The gate `routing/scripts/route-corpus.mjs`
+enforces RT1–RT10 offline, including **RT2b**, the mechanical D-12 proof: every pinned origin must equal
+its gazetteer landmark's committed coordinate within 1e-4° (≈ 11 m) on both axes. A
+user coordinate therefore *cannot* enter a fixture.
+
+**Defensive validation** (the `bot/src/kv/location-pin.ts` precedent): a malformed
+pair — non-finite distance/duration, geometry that throws on decode, empty or
+oversized routes array — is **skipped at index-build time**. It renders as an honest
+null downstream, never a throw in the webview's render path and never a NaN coordinate.
+
+### 1.7 Backend #2: the Worker router
+
+`bot/src/routing/worker-router.ts` — `handleRoute(req, env)`.
+
+```
+POST /api/route { "origin": {lat, lng}, "destinationId": "" }
+```
+
+The pipeline, in order:
+
+1. **Bearer gate.** The token resolves through `getClaimByToken`
+ (`bot/src/kv/sessions.ts`) to a claim. No/invalid token → `401 unauthorized`. The
+ claim is the identity gate; routing itself is chat-agnostic.
+2. **Body cap.** `MAX_ROUTE_BODY_CHARS` = 4096, checked against the declared
+ `Content-Length` **and** the post-read length, both **before** any parse work.
+ Over-cap → `413`.
+3. **Shape validation.** `validateRouteBody` — finite `lat`/`lng`, `destinationId`
+ matching `^[a-z0-9-]{1,64}$`. Fail → `400 malformed`.
+4. **Graph load.** `loadGraph(env)` — see below. `null` → `200 null`.
+5. **Bbox range check (V5).** The origin is range-checked against `graph.bbox`
+ **before any snap/A\* work** — untrusted coordinates never reach the compute-heavy
+ path. Fail → `400 malformed`.
+6. **Destination short-circuit.** `getEntryById(destinationId)` from
+ `@araharah/search` — an unknown destination short-circuits before **any** graph
+ work. `null` → `200 null`.
+7. **Snap origin.** `snapToGraph(graph, body.origin)` — `null` → `200 null`.
+8. **Snap destination.** `snapToGraph(graph, { lat: dest.lat, lng: dest.lng })` —
+ `null` → `200 null`.
+9. **Search.** `routeAstar(graph, originSnap.node, destSnap.node)` — `null` →
+ `200 null` (a disconnected pair).
+10. **Assemble.** One option only: geometry (node coordinates + derived
+ `cumulativeDistanceM`), summary (`astar.distanceM`, `astar.seconds`), and
+ `steps: deriveSteps(graph, astar.nodes)`. `backend: "engine-a"`.
+
+**ONE best route.** Alternatives generation is **prohibited** on this path — the
+recorded anti-pattern is 2–3 penalty runs inside an active-drive storm-guard window:
+CPU risk with zero UX need.
+
+**Every miss class answers `200` with a JSON `null` body.** That is the null contract
+crossing HTTP unchanged (D-03): the router-pending state, never a 500, never a blank
+map, never a fake route.
+
+**Logging law (D-14):** the only log lines are `"[route] rejected: "` —
+`unauthorized`, `malformed` — never coordinates, ids, or payloads.
+
+#### The graph load: isolate memoization is the cache that works
+
+```ts
+const graphMemo = new WeakMap