Files
arah2/.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-01-PLAN.md
T

387 lines
26 KiB
Markdown

---
phase: 08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- routing/src/contraction.ts
- routing/src/ch-query.ts
- routing/src/graph.ts
- routing/src/astar.ts
- routing/src/index.ts
- routing/src/landmark.ts
- routing/test/contraction.test.mjs
- routing/test/ch-query.test.mjs
- routing/test/graph.test.mjs
- routing/test/astar.test.mjs
- routing/test/landmark.test.mjs
- routing/test/fixtures/build-mini-graph.mjs
- routing/test/fixtures/mini-graph.bin
- routing/test/fixtures/mini-graph-arg2.bin
- bot/test/webhook.test.mjs
- bot/test/guidance-cron.test.mjs
autonomous: true
requirements:
- ROADMAP-08.1-GOAL
- D-04
- D-05
- D-07
- D-10
- D-11
- D-12
must_haves:
truths:
- "The routing package builds a deterministic contraction hierarchy with a strict total rank, valid upward shortcut edges, and an overlay-only witness search."
- "Every route request uses one bidirectional uphill CH query; the old ALT-steered bidirectional A* internals and landmark tables are absent from runtime code and binary output."
- "ARG3 is a little-endian typed-array layout that decodes only exact ARG3 bytes; ARG1 and ARG2 bytes decode to null and reach the existing router-pending state."
- "The reported route seconds are summed from the unpacked original-edge path, and every route remains optimal against the Dijkstra oracle."
- "The committed mini-graph fixture and bot test graphs are produced through the same ARG3 encode path as the production graph."
artifacts:
- path: "routing/src/contraction.ts"
provides: "Pure deterministic CH construction core: ordering, contraction, overlay witness search, shortcut emission"
- path: "routing/src/ch-query.ts"
provides: "Pure uphill bidirectional CH query, shortcut unpacking, and original-edge cost recomputation"
- path: "routing/src/graph.ts"
provides: "ARG3 codec authority: rank + shortcut suffix, exact-size and alignment validation"
- path: "routing/test/contraction.test.mjs"
provides: "Construction invariants, hand fixture, overlay witness law, tripwire, determinism"
- path: "routing/test/ch-query.test.mjs"
provides: "Directed oneway, original-edge seconds, one-path, and deleted-ALT structural pins"
- path: "routing/test/fixtures/mini-graph-arg2.bin"
provides: "Immutable ARG2 decode-rejection vector"
key_links:
- from: "routing/src/contraction.ts"
to: "routing/src/graph.ts"
via: "ContractionResult rank + shortcut CSR encoded through GraphInput.ch"
pattern: "contractGraph|encodeGraph"
- from: "routing/src/ch-query.ts"
to: "routing/src/graph.ts"
via: "zero-copy RoadGraph chRank and shortcut CSR views"
pattern: "chRank|shortcutStart"
- from: "routing/src/astar.ts"
to: "routing/src/ch-query.ts"
via: "routeAstar delegates every request to the uphill CH query"
pattern: "routeCH|ch-query"
---
<objective>
Implement the routing-backend-v3 data plane and its single search path: build-time contraction with shortcut edges, ARG3 binary persistence, and one uphill bidirectional CH query used by every request.
Purpose: This is the mechanism that closes SC-1. The hierarchy prunes the request-time search structurally, while D-04/D-05 remove the ALT engine and its 28.5 MB landmark suffix rather than leaving a dormant fallback.
Output: Pure CH construction/query modules, ARG3 codec, CH-backed routeAstar, ARG3 fixtures, and updated routing/bot test suites.
</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>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-CONTEXT.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-VALIDATION.md
@routing/src/graph.ts
@routing/src/astar.ts
@routing/src/landmark.ts
@routing/src/index.ts
@routing/test/graph.test.mjs
@routing/test/astar.test.mjs
@routing/test/fixtures/build-mini-graph.mjs
@routing/test/rollout.test.mjs
</context>
<interfaces>
Existing contracts to preserve:
```typescript
// routing/src/astar.ts
export interface AstarResult {
nodes: number[];
seconds: number;
distanceM: number;
}
export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null;
export function deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[];
export function classifyTurn(prevBearingDeg: number, nextBearingDeg: number): GuidanceTurnKind;
```
New internal contracts to implement:
```typescript
// routing/src/contraction.ts
export interface ContractionInput {
nodeCount: number;
edges: Array<{ from: number; to: number; seconds: number; flags: number }>;
}
export interface ContractionResult {
rank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
stats: {
shortcutCount: number;
ratioPerNode: number;
witnessSearches: number;
witnessBudgetHits: number;
};
}
export const CH_LAMBDA_DELETED_NEIGHBOURS = 1;
export const CH_WPS_MAX_SETTLED = 60;
export const CH_WPS_SETTLED_PER_DEGREE = 4;
export const CH_SHORTCUT_EXPLOSION_RATIO = 4;
export function contractGraph(input: ContractionInput): ContractionResult;
// routing/src/graph.ts
export interface ChTablesInput {
rank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
}
export interface RoadGraph {
chShortcutCount: number;
chRank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
}
```
ARG3 fork contract to implement exactly:
```text
header 72 bytes, little-endian:
0 magic u32 = 0x41524733 ("ARG3")
4 version u32 = 3
8 nodeCount u32
12 edgeCount u32
16 nameCount u32
20 namePoolLen u32
24 bbox 4 x f64
56 maxSpeedKmh f32
60 chShortcutCount u32 (0 -> decode null)
64 reserved0 u32
68 reserved1 u32
sections:
lat[N] ++ lng[N] ++ edgeStart[N+1] ++ edgeTarget[E] ++ edgeFlags[E]
++ edgeSeconds[E] ++ edgeNameId[E] ++ nameOffset[M+1] ++ namePool
++ chRank[N] ++ shortcutStart[N+1] ++ shortcutTarget[S]
++ shortcutSeconds[S] ++ shortcutVia[S]
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>task 1: build the pure CH construction core with RED-first invariants</name>
<files>routing/src/contraction.ts, routing/test/contraction.test.mjs</files>
<read_first>
- routing/src/landmark.ts
- routing/src/graph.ts
- routing/test/landmark.test.mjs
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
</read_first>
<behavior>
- Case C1: contractGraph over a 5-node directed fixture returns distinct ranks covering exactly [0, nodeCount).
- Case C2: every emitted shortcut's seconds equal the sum of the weights of the two edges it replaced.
- Case C3: every shortcut is upward, with rank(from) > rank(via) and rank(to) > rank(via).
- Case C4: a hand-computed 3-4 node fixture yields its literal expected contraction order and shortcut set.
- Case C5: the witness search sees only the overlay graph; a previously contracted node cannot witness a shortcut.
- Case C6: the shortcut-explosion tripwire fails loud above CH_SHORTCUT_EXPLOSION_RATIO * nodeCount and passes for a healthy graph.
- Case C7: two calls on identical input produce byte-identical rank and shortcut arrays.
</behavior>
<action>
Write `routing/test/contraction.test.mjs` first using the existing lazy-import harness, `node:assert/strict`, and hand-built vectors. Then create `routing/src/contraction.ts` as a pure module with zero value imports, no I/O, no logging, and no storage.
Implement these exact constants and exports:
- `CH_LAMBDA_DELETED_NEIGHBOURS = 1`
- `CH_WPS_MAX_SETTLED = 60`
- `CH_WPS_SETTLED_PER_DEGREE = 4`
- `CH_SHORTCUT_EXPLOSION_RATIO = 4`
- `ContractionInput { nodeCount, edges: Array<{from,to,seconds,flags}> }`
- `ContractionResult { rank, shortcutStart, shortcutTarget, shortcutSeconds, shortcutVia, stats }`
- `contractGraph(input): ContractionResult`
Implement the bottom-up CH algorithm exactly:
1. Validate node ids and finite non-negative edge seconds before constructing CSR views. An edge with `flags & 1` is blocked in every direction and must never be relaxed.
2. Compute priority as `edgeDifference(u) + CH_LAMBDA_DELETED_NEIGHBOURS * deletedNeighbours(u)`. Use a lazy binary priority queue. When a popped priority is stale, recompute it and requeue if it improved. Break priority ties by the lower node id.
3. Assign `rank[u]` as the strictly increasing contraction position, never as the priority value. Thus rank is a total order over all nodes.
4. For each node `u` being contracted, enumerate overlay in-neighbours `a` and overlay out-neighbours `b`. The candidate shortcut is directed `a -> b`, with seconds `w(a,u) + w(u,b)` and `via = u`.
5. Run each witness search on the overlay graph with `u` removed and all previously contracted nodes absent. Bound it by both the candidate distance and `max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree)`. If the witness search is bounded out or finds no path within the candidate distance, emit the shortcut; bounded witness searches may add superfluous shortcuts but must not remove a required one.
6. Remove `u` and its incident overlay edges before the next witness search. Emit shortcuts in deterministic order: ascending `from`, then ascending `to`.
7. Build shortcut CSR arrays where `shortcutStart` has length `nodeCount + 1` and every source's shortcuts are contiguous.
8. Fail loud if `shortcutCount > CH_SHORTCUT_EXPLOSION_RATIO * nodeCount`, with a message naming the ratio and the measured count.
9. Populate `stats` with `shortcutCount`, `ratioPerNode = shortcutCount / nodeCount`, `witnessSearches`, and `witnessBudgetHits`.
Determinism is mandatory: no PRNG, no `Date`, no Map iteration order, no reliance on object key order. Identical input arrays must yield identical output arrays. Do not use any external CH library and do not add a dependency.
</action>
<verify>
<automated>npm run routing:test -- --test-name-pattern='C[1-7]' &amp;&amp; npm run routing:typecheck</automated>
</verify>
<acceptance_criteria>
- `routing/src/contraction.ts` contains `export function contractGraph` and all four `CH_*` constants with the exact values 1, 60, 4, and 4.
- `routing/test/contraction.test.mjs` contains cases whose names include C1, C2, C3, C4, C5, C6, and C7.
- `npm run routing:test` exits 0 with no FAIL lines for the C-series cases.
- `npm run routing:typecheck` exits 0.
- `grep -v '^[[:space:]]*\*' routing/src/contraction.ts | grep -v '^[[:space:]]*//' | grep -c 'Date\|Math.random'` equals 0.
</acceptance_criteria>
<done>CH construction is deterministic, rank is a strict total order, shortcut weights and upwardness are proven, the overlay-only witness law is pinned, and the explosion tripwire fails loud.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: implement ARG3 codec persistence and the uphill bidirectional CH query</name>
<files>routing/src/graph.ts, routing/src/ch-query.ts, routing/src/astar.ts, routing/src/index.ts, routing/src/landmark.ts, routing/test/ch-query.test.mjs, routing/test/graph.test.mjs, routing/test/astar.test.mjs, routing/test/landmark.test.mjs</files>
<read_first>
- routing/src/graph.ts
- routing/src/astar.ts
- routing/src/landmark.ts
- routing/src/index.ts
- routing/test/graph.test.mjs
- routing/test/astar.test.mjs
- routing/test/rollout.test.mjs
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
</read_first>
<behavior>
- Case Q1: the CH query equals brute-force Dijkstra for every pair of the 25-node mini-graph, with path endpoints equal to the requested origin and goal.
- Case Q2: a directed ring/chord/spur fixture matches the Dijkstra oracle in both directions for every pair and never traverses a bit0 edge.
- Case Q3: the returned seconds equal a fresh sum over the returned original-edge path and never equal a sum of shortcut weights.
- Case Q4: an unreachable pair returns null.
- Case Q5: comment-stripped `astar.ts` contains none of `landmarkTo`, `landmarkFrom`, `cleanColumnsFor`, `LANDMARK_SENTINEL`; `routing/src/landmark.ts` and `routing/test/landmark.test.mjs` are absent; the index exports no landmark symbols.
- Case G1: ARG3 round-trips rank and shortcut CSR exactly through zero-copy views.
- Case G2: ARG1 bytes and ARG2 bytes both decode to null; a version-2 tamper decodes to null.
- Case G3: truncated, hostile-count, and `chShortcutCount = 0` buffers decode to null before any typed-array allocation.
- Case G4: the committed ARG3 mini-graph has 25 nodes, 80 edges, a rank of length 25, and at least one shortcut.
</behavior>
<action>
Replace the ARG2 codec in `routing/src/graph.ts` with the ARG3 codec described in the interfaces block. Use `GRAPH_MAGIC = 0x41524733` and `GRAPH_VERSION = 3`. Keep `HEADER_SIZE = 72` so f64 views remain 8-byte aligned. Replace landmark header fields with `chShortcutCount` u32 @60 and reserved u32 @64/@68. Remove `landmarkCount`, `landmarkQuantumS`, `landmarkTo`, `landmarkFrom`, and `landmarkNodes` from `RoadGraph`, `LandmarkTablesInput`, and `GraphInput`; replace them with `ChTablesInput` and `GraphInput.ch`.
In `layoutFor`, calculate the CH suffix in this order and use 4-byte alignment before every u32/f32 section: `chRank = (namePool + namePoolLen + 3) & ~3`, then `shortcutStart = chRank + 4*nodeCount`, `shortcutTarget = shortcutStart + 4*(nodeCount+1)`, `shortcutSeconds = shortcutTarget + 4*shortcutCount`, `shortcutVia = shortcutSeconds + 4*shortcutCount`, `total = shortcutVia + 4*shortcutCount`. Run the exact-size arithmetic check before allocating any view. `chShortcutCount === 0` must return null even when the byte size matches. `decodeGraph` must never throw for malformed bytes and must never return a half-built graph.
Create `routing/src/ch-query.ts` with the pure uphill bidirectional CH query. Its public result shape must be `{ nodes: number[]; seconds: number; distanceM: number }`. The forward search relaxes only edges whose target rank is strictly greater than the source rank. The backward search relaxes only incoming edges whose source rank is strictly greater than the settled node's rank. Use separate forward and backward edge access; never reuse the forward shortcut list in the backward search. Apply the shared traversal law in every scan: `if ((edgeFlags[e] & 1) !== 0) continue;`.
The stop rule is `if (forwardHeap.peekKey() + backwardHeap.peekKey() >= mu) break;`. Alternating the smaller frontier first is only a work-balance knob and must not affect optimality. Use the edge-based meet probe already present in astar.ts. After reconstructing the parent chains, recursively unpack every shortcut through its `via` field until only original edges remain. THEN compute `seconds` by summing `graph.edgeSeconds[findTraversableEdge(...)]` over consecutive original-edge pairs, and compute `distanceM` by summing haversine over that same unpacked path. Shortcut weights steer the search only; they never enter reported seconds.
Rewrite `routing/src/astar.ts` so `routeAstar` validates origin/goal, handles origin === goal, and delegates to the CH query for every other request. Preserve `AstarResult`, `deriveSteps`, `classifyTurn`, and all exported turn constants exactly. Delete the ALT machinery: `LANDMARK_SENTINEL_U16`, `cleanColumnsFor`, the `cleanColumnGraphs` memo, `metersTo`, `bound`, `potential`, the ALT/BH search body, and every landmark-table reference. Per D-04 there is exactly one search path.
Update `routing/src/index.ts` to export the existing public API and additively export `contractGraph`, `ContractionInput`, `ContractionResult`, and the four `CH_*` constants if the query implementation needs them. Export no landmark symbols.
Delete `routing/src/landmark.ts` and `routing/test/landmark.test.mjs` per D-05. Re-pin `routing/test/graph.test.mjs` for ARG3 and re-point `routing/test/astar.test.mjs` A1-A8 at the CH-backed `routeAstar`; remove or fold the ALT-specific A9-A13 cases into the CH query suite rather than retaining landmark fixtures.
</action>
<verify>
<automated>npm run routing:test &amp;&amp; npm run routing:typecheck</automated>
</verify>
<acceptance_criteria>
- `routing/src/graph.ts` contains `GRAPH_MAGIC = 0x41524733` and `GRAPH_VERSION = 3` and contains no `landmarkTo`, `landmarkFrom`, or `landmarkCount`.
- `routing/src/ch-query.ts` exists and contains the upward-rank filter, the `peekKey()` stop rule, and shortcut unpacking through `via`.
- `routing/src/astar.ts` comment-stripped code contains none of `landmarkTo`, `landmarkFrom`, `cleanColumnsFor`, `LANDMARK_SENTINEL`.
- `routing/src/landmark.ts` and `routing/test/landmark.test.mjs` do not exist.
- `npm run routing:test` exits 0 and `npm run routing:typecheck` exits 0.
- `grep -v '^[[:space:]]*\*' routing/src/astar.ts | grep -v '^[[:space:]]*//' | grep -c 'landmarkTo\|landmarkFrom\|cleanColumnsFor\|LANDMARK_SENTINEL'` equals 0.
</acceptance_criteria>
<done>ARG3 is the sole binary format, the CH uphill query is the sole search path, ALT code and tables are gone, and reported seconds come from the unpacked original-edge path.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: rebuild all fixtures and bot graph builders through the ARG3 path</name>
<files>routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin, routing/test/fixtures/mini-graph-arg2.bin, bot/test/webhook.test.mjs, bot/test/guidance-cron.test.mjs</files>
<read_first>
- routing/test/fixtures/build-mini-graph.mjs
- routing/test/fixtures/mini-graph.bin
- routing/test/graph.test.mjs
- bot/test/webhook.test.mjs
- bot/test/guidance-cron.test.mjs
- routing/src/contraction.ts
- routing/src/graph.ts
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
</read_first>
<behavior>
- The committed mini-graph fixture decodes as ARG3 with 25 nodes, 80 edges, and a valid CH section.
- The preserved ARG2 fixture decodes to null.
- Every bot test graph encodes and decodes through the ARG3 `ch` block and produces the same route behavior as before.
</behavior>
<action>
Before regenerating the mini-graph, copy the current committed `routing/test/fixtures/mini-graph.bin` bytes exactly once to `routing/test/fixtures/mini-graph-arg2.bin`. Add a provenance comment to `build-mini-graph.mjs` stating that `mini-graph-arg2.bin` holds the frozen ARG2-era bytes preserved as the ARG2 decode-rejection vector and is never regenerated by the script, matching the existing `mini-graph-arg1.bin` precedent.
Replace the landmark stage in `routing/test/fixtures/build-mini-graph.mjs` with a CH stage. Import `contractGraph` from `../../src/contraction.ts`, call it with the 25-node grid's directed edges, and pass its result as `ch:` to `encodeGraph`. Before writing, fail loud unless: `new Set(rank).size === 25`; every rank is in [0,25); the shortcut count is within the expected grid band; every `via` is a node id in [0,25); every shortcut's seconds equal its two constituent edge weights; and the graph remains strongly connected.
Update `bot/test/webhook.test.mjs`: replace the `landmark.ts` import and `landmarkBlock()` with a `chBlock(nodeCount, edges)` helper that computes `contractGraph` and returns the CH block. Replace every `landmarks: { ... }` argument to `encodeGraph` with `ch: { ... }`. There are three graph builders in this file; all must use the ARG3 path.
Update `bot/test/guidance-cron.test.mjs`: replace the pinned-anchor 2-node graph builder with a `contractGraph` call and encode with `ch:`. The 2-node graph must contract successfully; no anchor pin is allowed.
Run `node routing/test/fixtures/build-mini-graph.mjs` to regenerate `mini-graph.bin` as ARG3 and confirm the printed node/edge/shortcut/byte counts.
</action>
<verify>
<automated>node routing/test/fixtures/build-mini-graph.mjs &amp;&amp; npm run routing:test &amp;&amp; npm test -w bot &amp;&amp; npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- `routing/test/fixtures/mini-graph-arg2.bin` exists and `node -e "const fs=require('fs');const b=fs.readFileSync('routing/test/fixtures/mini-graph-arg2.bin');console.log(b.readUInt32LE(4))"` prints `2`.
- `node -e "const fs=require('fs');const b=fs.readFileSync('routing/test/fixtures/mini-graph.bin');console.log(b.readUInt32LE(0).toString(16),b.readUInt32LE(4))"` prints `41524733 3`.
- `grep -v '^[[:space:]]*\*' bot/test/webhook.test.mjs | grep -v '^[[:space:]]*//' | grep -c 'landmark'` equals 0.
- `grep -v '^[[:space:]]*\*' bot/test/guidance-cron.test.mjs | grep -v '^[[:space:]]*//' | grep -c 'landmark'` equals 0.
- `npm run routing:test`, `npm test -w bot`, and `npm run bot:typecheck` exit 0.
</acceptance_criteria>
<done>Every committed and bot test graph is ARG3-shaped, the old ARG2 bytes survive only as an immutable rejection vector, and the routing and bot suites stay green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| R2 object -> Worker decoder | The R2-held road-graph bytes are untrusted to the decoder: corruption, truncation, hostile counts, or misalignment must produce null, never a throw or half-built graph. |
| Request -> Worker -> CH query | Untrusted route parameters reach the search only after the existing Bearer claim and bbox validation; the CH query must not broaden that surface. |
| Build pipeline -> binary | The Overpass-derived graph is transformed by build-time contraction into the persisted hierarchy; a stale hierarchy against changed topology is a build-artifact discipline problem. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08.1-01 | Tampering / DoS | `decodeGraph` ARG3 header and section arithmetic | mitigate | `layoutFor` computes every offset in one place; exact-size validation runs before any typed-array allocation; `chShortcutCount === 0` returns null; malformed bytes return null rather than throwing. Pinned by graph.test.mjs G3/G4/G11 successors. |
| T-08.1-02 | DoS | Misaligned `Float32Array`/`Uint32Array` CH views | mitigate | Keep `HEADER_SIZE = 72` for 8-byte f64 alignment and align the CH suffix with `(namePool + namePoolLen + 3) & ~3`; G1 round-trip and hostile-buffer tests decode the committed ARG3 fixture without RangeError. |
| T-08.1-03 | Tampering | Directed shortcut confusion across oneway edges | mitigate | Shortcuts are directed only; forward and backward searches use separate edge access; bit0 edges are never relaxed in forward, backward, or witness scans; the directed ring/chord/spur sweep compares both directions with the Dijkstra oracle. |
| T-08.1-04 | Tampering | Stale CH hierarchy after topology or weight changes | mitigate | ARG3 is rebuilt and re-uploaded as one whole artifact under the unchanged key; `docs/ROAD-GRAPH.md` records that the hierarchy is valid only for the topology and weights it was built against. |
| T-08.1-05 | DoS | Unbounded build-time witness search | mitigate | Witness search is bounded by candidate distance and `max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree)`; build prints witness-search and budget-hit counts. |
| T-08.1-06 | Tampering / DoS | Shortcut explosion inflating binary and query cost | mitigate | `CH_SHORTCUT_EXPLOSION_RATIO = 4` fails the build loud; the existing 1-80 MB envelope remains enforced; no fixed payload ceiling is invented. |
| T-08.1-07 | Information Disclosure | Coordinates, route payloads, or user identity in logs | accept | The phase adds no logging and changes no `/api/route` response shape; existing category-only log law and Bearer-claim identity remain unchanged. No new log statement may be introduced in routing modules. |
| T-08.1-08 | Spoofing / Tampering | Dormant ALT path or second decoder producing a different answer | mitigate | D-04/D-05 deletion is pinned structurally: no ALT tokens in astar.ts, landmark.ts absent, no landmark exports, one decode path in the Worker; routeAstar delegates every request to the CH query. |
| T-08.1-09 | DoS | CPU exhaustion on the request path | mitigate | The uphill query uses the `peekKey()` stop rule and returns null for unreachable pairs; the local CPU gate and tightened drift guard in Plan 02 are the enforcement point. |
</threat_model>
<verification>
Run the full automated suite after this plan:
```bash
npm run routing:test && npm test -w bot && npm run routing:typecheck && npm run bot:typecheck && npm run web:typecheck
```
Also verify structural deletion and the one-path law:
```bash
test ! -e routing/src/landmark.ts && test ! -e routing/test/landmark.test.mjs
grep -v '^[[:space:]]*\*' routing/src/astar.ts | grep -v '^[[:space:]]*//' | grep -c 'landmarkTo\|landmarkFrom\|cleanColumnsFor\|LANDMARK_SENTINEL'
grep -c 'decodeGraph(' bot/src/routing/worker-router.ts
```
Expected: routing and bot suites green; all typechecks green; structural ALT count 0; Worker decodeGraph count 1.
</verification>
<success_criteria>
- CH construction and query behavior is proven by the routing unit suite, including a hand-computed fixture, overlay witness law, oneway direction, and original-edge seconds.
- ARG3 is the only encoded/decoded format; ARG1 and ARG2 decode to null through the existing fail-honest path.
- `routeAstar` is the sole exported search and is CH-backed for every request; no ALT runtime code or landmark payload remains.
- The committed mini-graph and both bot graph builders are ARG3-shaped and produced through the same encode path.
- `npm run routing:test`, `npm test -w bot`, and all three typechecks pass.
</success_criteria>
<output>
After completion, create `.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-01-SUMMARY.md`
</output>