Why we built our own routing core in Rust

# Why we built our own routing core in Rust

MapMap runs on one routing core that we own: a Rust library that compiles once and reaches Android through Kotlin bindings, iOS through Swift bindings and Node as WebAssembly. It sits over a graph search we extend ourselves, so a truck's height, weight and ADR tunnel code shape the route rather than filter it afterwards. We did not resell a hyperscaler's routing API, and we did not glue three engines together behind one wrapper. Either choice would have put the thing our customers are actually buying, a route their vehicle is legally allowed to drive, outside our control.

## What "our own core" actually means

It means two Rust crates plus one bounded C++ patch series, not a from-scratch rewrite of every wheel in the shed.

| Piece | What it is |
|---|---|
| `sn-adr` | The ADR tunnel-restriction domain model. Single source of truth for dangerous-goods semantics across the gateway, the MCP server and the mobile SDKs. It is the executable form of `SPEC-ADR-COSTING.md`, derived from public sources only: ADR 8.6.4 and 1.9.5.2.2, OSM `Key:hazmat`, plus GraphHopper's Apache-2.0 hazmat-tunnel parser as a public behavioural reference. |
| `sn-nav-core` | The on-device navigation core: signed territory packages, route-request building, the debounced off-route and reroute controller, the turn-by-turn guidance session (built on Ferrostar, BSD-3-Clause) and the UniFFI 0.31 exports under the namespace `mapmap`. |
| Graph search | Valhalla 3.8.2 (MIT), pinned to an exact upstream commit, plus a four-patch series of ours that adds ADR tunnel-category costing to truck routing. |

That last row is the honest part. Valhalla is C++, and our repository is otherwise Rust only. The fork is the single permitted exception, four patches wide, offered upstream under MIT. We did not need to write a continental-scale graph search from scratch. We needed the costing to be ours.

## Why one core instead of three

Because a device and a server that disagree about whether a tunnel is legal is a compliance defect, and three separate codebases will eventually disagree.

The Kotlin and Swift bindings are generated from `sn-nav-core` by UniFFI, so the tunnel decision an Android driver sees offline is produced by the same compiled function the hosted API calls. Node gets the same code compiled to WebAssembly and published as `@mapmap/core`, the npm artefact being the Node build:

```js
const { checkTunnel } = require("@mapmap/core");

// May a tunnel-code C load use a category D tunnel? (ADR 8.6.4, worst-case)
checkTunnel({ hazmat: true, tunnelCode: "C" }, "D");
// => { status: "blocked", reason: "..." } or { status: "allowed" }
```

`checkTunnel` here, `check_tunnel` over the FFI in Kotlin and Swift, `POST /adr/check` on the gateway: one implementation with three doors. How those bindings are built is the subject of [shipping four SDKs from one Rust core](/news/mobile-sdks-one-rust-core).

Where a second implementation was genuinely unavoidable, inside the C++ costing, we did not rely on discipline to keep the two in step. `fork/adr_matrix_test_vectors.json` is a shared truth table, hand-encoded from the spec, and both sides assert against the same cells: the `sn-adr` unit tests in Rust and the gurka suite in C++. If either side changes, both change in the same commit.

## Why a truck constraint has to shape the search, not filter its output

Because a filter can only reject a finished route, it cannot produce the legal one. This is the whole argument, and it is why a wrapper around somebody else's costing cannot give you a constraint that costing does not already understand.

Post-filtering looks reasonable on a whiteboard. Ask a general-purpose engine for a route, inspect the geometry against your constraints, reject it if it breaks one, ask again. The problem is what "ask again" means. The engine returned the fastest route because that is what you asked for. It does not know why you rejected it, so it has no reason to hand you anything materially different.

The worse failure is quieter: a route that is wrong in a way that looks right. The distance is plausible, the duration is plausible, the geometry is a sensible line across the country, and 60 kilometres in there is a category D tunnel this load may not enter. Nothing in the response says so. Your driver finds out at the tunnel mouth, in front of a sign, with a load of petrol behind them.

In our stack the constraint lives inside the search. Our patch series puts the ADR tunnel category on the directed edge itself, as a 3-bit attribute in bits that were already spare, leaving tile size and layout unchanged. It extracts those categories from OSM hazmat tagging at tile-build time. It adds an `adr_tunnel_code` truck costing option that hard-blocks the edge in `TruckCost::Allowed` and `AllowedReverse`.

The search never expands a forbidden edge. What comes back is a route assembled only from edges the vehicle may legally use, and when no such route exists you get `NoRoute`, which is a true answer and a useful one. The semantics in one line: with `hazmat=true`, passage is denied through a tunnel of category `cat` when `cat` is at or above the first letter of the declared `adr_tunnel_code`. Conditional codes (`B1000C`, `C/E` and friends) are read worst-case, because the API cannot know your net explosive mass or whether the goods travel in tanks. No declared code at all collapses to `B`, the most restrictive reading. The full code table is in [the API conventions](/docs/conventions).

The same logic applies to dimensions, which is a longer story told in [how truck dimensions shape the route](/news/truck-routing-dimensions), and to fleet planning. `POST /optimise` solves over a matrix computed by our own engine, so truck and ADR constraints shape the plan instead of being checked after the solver has already committed to it. That endpoint is enabled per deployment: where the operator has not configured a solver it answers `503`, and that is deliberate.

## Why permissive licensing was a hard constraint from day one

Because distribution is the business model. We ship binaries onto vehicles and into customers' own datacentres, so copyleft inside a distributable is not a licensing debate, it is a shipped defect.

The gate is `cargo-deny`, running on the dependency graph of everything we distribute. The allow list is MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, Zlib, BSL-1.0, Unicode-3.0, CC0-1.0, 0BSD and a short tail of equivalents. GPL, LGPL, AGPL, MPL, SSPL, EUPL and CDDL fail the build. There is exactly one exception, scoped per crate rather than blanket: the UniFFI family, which is MPL-2.0 and unavoidable because Ferrostar's bindings are UniFFI-generated. MPL-2.0 is file-scoped, we ship those files unmodified, and the SBOM script fails if any crate outside that family reports MPL-2.0.

What the rule ruled out, concretely, was the GPL-licensed end of the open geo toolchain. That is why geocoding is either our own first-party `sn-geocode` engine running in-process from a territory index, or a Photon proxy the operator runs, and never a copyleft search stack bolted into the distributable. It is a real cost. We wrote code we could otherwise have installed.

You do not have to take our word for it. Every tagged release publishes a CycloneDX SBOM with a licence inventory, shipped with the procurement pack, so your legal team can check rather than trust us. Routing data derives from OpenStreetMap, so credit © OpenStreetMap contributors when you render or republish it.

## Which profiles come out of the one engine

All of them, from the same core and the same tiles. On `POST /route` the `costing` value selects the profile; the compatible URL endpoint takes the aliases in the middle column.

| `costing` | Aliases on `/route/v1/{profile}/…` | Price class |
|---|---|---|
| `auto` | `driving`, `car`, `auto` | Standard |
| `truck` | `truck` | Premium |
| `bus` | `bus` | Standard |
| `motorcycle` | `motorcycle`, `motorbike` | Standard |
| `motor_scooter` | `scooter` | Standard |
| `bicycle` | `bicycle`, `bike`, `cycling` | Standard |
| `pedestrian` | `walking`, `foot`, `pedestrian` | Standard |

Anything else is rejected with `InvalidValue`. The truck and ADR parameters require the `truck` profile specifically, and sending them with any other profile is a `400`.

```bash
export BASE=https://api.mapmap.ai
export API_KEY=snk_...

curl "$BASE/route/v1/truck/-0.1276,51.5072;-1.8904,52.4862?height=4.0&weight=44.0&hazmat=true&tunnel_code=D" \
-H "Authorization: Bearer $API_KEY"
```

```json
{
"code": "Ok",
"routes": [
{
"distance": 190843.0,
"duration": 8611.0,
"weight": 8611.0,
"weight_name": "duration",
"geometry": "u{~vFvyys@fS]...",
"legs": ["..."]
}
],
"waypoints": [
{ "name": "Victoria Embankment", "location": [-0.1276, 51.5072] },
{ "name": "Corporation Street", "location": [-1.8904, 52.4862] }
]
}
```

Every parameter above is documented in [the API reference](/docs/api-reference).

## What it costs

Standard routing is 0.05p a call and truck or ADR routing is 1p, on top of a free tier of 50,000 included calls a month.

| Thing | Price |
|---|---|
| Free tier | 50,000 included calls per calendar month after email verification, commercial use allowed |
| Provisional key | 1,000 calls over 72 hours, issued in one call, card-free |
| Standard calls | 0.05p, then 0.03p beyond 1M/month, then 0.02p beyond 10M |
| Premium calls (truck and ADR) | 1p, then 0.7p beyond 1M/month, then 0.4p beyond 10M |

A premium call draws 20 included calls, matching the price ratio, so an all-truck free tier is 2,500 calls a month. Paid usage is prepaid credit only. With no credit you get a `402` or a `429`, never a surprise invoice.

## What this does not give you

One core removes a class of drift. It does not remove every limit, and these are the ones worth knowing before you build against us.

- **ADR enforcement needs an ADR-capable backend.** Self-hosters select the engine with `SN_ROUTING_ENGINE`, which takes `valhalla` (the default), `graphhopper` or `auto`. Against a backend without our ADR costing the dimensions still apply, but the tunnel restriction code is not enforced, and there is no per-response warning field telling you so. `POST /adr/check` is always authoritative.
- **The `adr` extension only applies to `POST /route` and `POST /optimise`.** The analysis endpoints (`/isochrone`, `/matrix`, `/trace_route`, `/trace_attributes`) apply no ADR restrictions and silently ignore an `adr` object. Do not use them for dangerous-goods compliance.
- **Conditional codes are read worst-case.** If your load's real quantity or carriage mode would qualify it for a more permissive reading, we still route it the restrictive way. We would rather cost you a detour than a prosecution.
- **The mobile SDKs need early access.** The web packages install from npm today. The Android AAR and the iOS XCFramework ship from the same `sdk-v*` tags, but the source repository is private, so installing them means talking to us first.

## Try it

Issue a key card-free with `POST /v1/keys` and run the truck route above. If you want the whole stack on your own hardware, the [self-host distro](/docs/self-host) is one Docker Compose file and it is air-gap capable.

This is the first entry in a build log. The posts that follow take one decision each and show the code underneath it, starting with how truck dimensions shape the search and how four SDKs come off one Rust core.