ADR tunnel compliance, enforced in costing

# ADR tunnel compliance, enforced in costing

A tanker whose load carries ADR tunnel restriction code `C/E` may not pass
through a category-D tunnel under the worst-case reading of ADR 8.6.4. MapMap
knows that inside the cost model, before a route exists, so the geometry that
comes back is one the vehicle can legally drive rather than one you have to
check afterwards. We also sell the decision on its own: `POST /adr/check`
returns the tunnel-entry verdict, with the rule that produced it, and no
routing at all, at 1p per call. As of July 2026, MapMap is the only navigation
API offering a self-serve ADR tunnel-compliance product at published per-call
prices.

## What does an ADR tunnel restriction code actually restrict?

It restricts which categories of tunnel a dangerous-goods load may pass
through, and nothing else. Two attributes meet at the tunnel mouth. The tunnel
has a category from A to E, assigned by the national competent authority and
posted on the approach. The load has a restriction code from ADR Table A,
column (15), and under ADR 8.6.3 the code of the whole load is the most
restrictive code of any item on board.

The four unconditional codes are the simple case:

| Code | Passage forbidden through |
|---|---|
| `B` | Categories B, C, D and E |
| `C` | Categories C, D and E |
| `D` | Categories D and E |
| `E` | Category E |

The rest are conditional. `B1000C` bites at category B only above 1,000 kg
total net explosive mass per transport unit. `C5000D` bites at category C only
above 5,000 kg. The slashed codes (`B/D`, `B/E`, `C/D`, `C/E`, `D/E`) apply
their first clause only when the goods travel in tanks, or in bulk in the case
of `D/E`. A routing API does not know your net explosive mass and does not know
whether today's movement is in tanks, so we read every conditional clause as
applying. That collapses each code to a single threshold, which is its first
letter: passage is forbidden when the tunnel category is greater than or equal
to that threshold. The full 12-code table, including the ADR no-restriction
entry, is on the [API conventions page](/docs/conventions).

The direction of the error matters and it is deliberate. The worst-case reading
can route a load around a tunnel it was in fact entitled to use. It will never
send a load through a tunnel ADR could forbid.

## Why we enforce the tunnel category in costing rather than post-filtering

Because post-filtering answers a question nobody asked. If you plan a route
first and then scan it for forbidden tunnels, all you learn is whether that one
route happens to be legal. When the answer is no, you plan again, and nothing
about the second attempt makes it any more legal than the first. You end up
looping, or you end up shipping a "compliance check" that is really a rejection
notice.

We push the restriction down into the cost model instead. A tunnel the load
cannot enter is not an edge the search can traverse, so the shortest legal path
is simply what the search returns. There is no second pass. Either you get a
route the vehicle can drive, or you get no route, and no route is honest
information: it means the destination is not reachable under that load's
restrictions and someone needs to make a decision a router cannot make.

Dimensional gates work at the same layer for the same reason. Height, width,
length, weight and axle load are checked edge by edge against the OSM limits
during the search, not against the finished line on the map. We wrote that up
separately in [truck routing and vehicle dimensions](/news/truck-routing-dimensions).

## How do you get a compliance decision with no routing at all?

`POST /adr/check` takes a vehicle profile and a tunnel category and returns the
ADR 8.6.4 entry decision. No coordinates, no geometry, no map. It exists because
the compliance question and the routing question are genuinely separate, and
plenty of the people who need the first one are not planning a journey: they are
answering a question about a load, a tunnel and a rule.

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

curl -X POST "$BASE/adr/check" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"adr": {
"dimensions": {
"height_m": 4.0, "width_m": 2.55, "length_m": 16.5,
"gross_weight_t": 44.0, "axle_load_t": null, "axle_count": null
},
"tunnel_code": "C/E",
"hazmat": true
},
"tunnel_category": "D"
}'
```

The response is two fields:

```json
{
"status": "blocked",
"reason": "ADR 8.6.4: tunnel restriction code C/E forbids passage through tunnels of category D (worst-case reading; conditional clauses assumed to apply)"
}
```

A permitted load gets the short form, with no `reason` key at all:

```json
{ "status": "allowed" }
```

`tunnel_category` accepts `"A"` through `"E"`. `tunnel_code` accepts the
canonical ADR notation and is nullable. `hazmat` and `dimensions` are required.
The same decision is available to coding agents as the `check_adr_tunnel` tool
on our MCP server.

## Why a compliance decision you cannot explain is useless

Because the transport manager has to defend it, and "the API said no" is not a
defence. When a load is turned back, or an insurer asks why a vehicle was on a
particular road, or an auditor pulls six months of planning records, the person
answering needs the rule, not the verdict. A boolean is not evidence.

So `reason` is a sentence with four things in it: the section of the agreement
that applied (ADR 8.6.4), the restriction code that was in force (`C/E`), the
tunnel category it was tested against (D), and the disclosure that this was the
worst-case reading with conditional clauses assumed to apply. That last clause
is the honest part. It tells a reader who knows their load better than we do
exactly where our answer might be more conservative than the law requires, and
it does so in the response rather than in a footnote on a docs page they will
never read.

Log the string. It is written to be pasted into an audit trail and to still make
sense to a human 18 months later.

The asymmetry is deliberate and worth naming: an `allowed` decision carries no
`reason`, because there is no restriction to cite. If you need a positive
record, record the request alongside the response.

## How do you route the load rather than just check it?

Two ways, depending on which client you already have.

The compatible URL endpoint takes the load as vendor query parameters, so
OSRM-shaped tooling can send them with no structural change:

```bash
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"
```

| Param | Unit | Example |
|---|---|---|
| `height` | metres | `height=4.0` |
| `width` | metres | `width=2.55` |
| `length` | metres | `length=16.5` |
| `weight` | tonnes | `weight=44.0` |
| `hazmat` | boolean | `hazmat=true` |
| `tunnel_code` | ADR code | `tunnel_code=D` |

The native `POST /route` takes the whole load as a top-level `adr` object
alongside a Valhalla-style body. `dimensions`, `tunnel_code` (nullable) and
`hazmat` are all required keys, and the gateway merges them into
`costing_options.truck` for you:

```json
{
"locations": [
{ "lat": 51.5072, "lon": -0.1276 },
{ "lat": 52.4862, "lon": -1.8904 }
],
"costing": "truck",
"adr": {
"dimensions": {
"height_m": 4.0, "width_m": 2.55, "length_m": 16.5,
"gross_weight_t": 44.0, "axle_load_t": null, "axle_count": null
},
"tunnel_code": "C/E",
"hazmat": true
}
}
```

If your own `costing_options.truck` contradicts what the `adr` profile implies,
that is a `400 costing-conflict` with the offending fields listed, not a silent
pick of one over the other. Three semantics are worth knowing before you send
anything: `"hazmat": false` means the tunnel matrix does not apply at all
(dimensional limits still do), `"hazmat": true` with no `tunnel_code` is treated
as code `B` because the load's code is unknown, and the `adr` extension is
honoured only on `POST /route` and `POST /optimise`.

## What it costs

ADR compliance is our premium class, priced at 1p per call. That is 20 times the
0.05p standard rate, and we are not going to pretend otherwise: it is the thing
we built, and it is worth more than a car route.

| Monthly volume | Premium price per call |
|---|---|
| Included free tier | 50,000 included calls, of which a premium call draws 20 (so 2,500 all-premium calls) |
| 50,001 to 1,000,000 | 1p |
| 1,000,001 to 10,000,000 | 0.7p |
| Beyond 10,000,000 | 0.4p |

Bands are marginal, so each price applies only to the calls that fall inside it.
The premium class is selected automatically per request: `/adr/check`,
`"costing": "truck"`, a top-level `adr` object, `"hazmat": true`, or a truck
profile or truck query parameter on the compatible endpoint. Everything else
bills as standard. Payment is prepaid credit only, so there is no post-paid
overage and no surprise invoice: with no credit you get a `402` or a `429`.
Prices reconcile with [/pricing.json](https://mapmap.ai/pricing.json), which
wins if this page ever drifts.

## What this does not do

ADR is a legal framework and we implement one part of it: the tunnel-category
entry decision defined in 8.6.4. We do not classify your goods, assign the
restriction code, handle placarding, driver certification, national derogations
or time-of-day tunnel orders. You give us the code, we give you the decision.
The operator remains responsible for the movement.

Four more limits worth having in writing:

- **The worst-case reading is conservative on purpose.** Quantity-conditional
and tank/bulk-conditional codes are assumed to apply, so a load may be routed
around a tunnel it was entitled to use. The error only ever runs in the safe
direction, but it is a real error and it will cost you distance.
- **Tunnel categories come from OpenStreetMap tags.** We read
`hazmat:adr_tunnel_cat`, then `hazmat:tunnel_cat`, then infer from per-code
`hazmat:B=no` style tagging on any way, tunnels and their signed approach
corridors alike, including the `hazmat=no` plus `hazmat:E=yes` allowance
style used on Dutch tunnel routes. A tunnel with no category tagged is
treated as category A, no restriction. That is the correct legal default,
since ADR restrictions apply where a category has been assigned and signed,
but it does mean coverage depends on the data.
- **Tunnel-code enforcement needs an ADR-capable routing backend.** Against a
backend without ADR costing, the dimensions still apply but the tunnel
restriction code is not enforced, and there is no per-response warning field
to tell you. `POST /adr/check` is always authoritative.
- **The analysis endpoints apply no ADR restrictions.** `/isochrone`, `/matrix`,
`/trace_route` and `/trace_attributes` ignore an `adr` object if you send one.
Do not build a dangerous-goods compliance step on top of them.

## Try it

Run `POST /adr/check` from the browser with no install at the
[playground](/playground), or read the request and response shapes in full in
the [API reference](/docs/api-reference). A key comes from one card-free call
to `POST /v1/keys` and works immediately.

Routing derives from OpenStreetMap, so credit "© OpenStreetMap contributors"
when you render or republish it.