# Territory packages & OTA updates

Offline navigation runs on **territory packages**: signed, per-territory
bundles of everything a device needs: routing tiles, base-map tiles and a
geocode index (offline search, including points of interest),
built by our map factory (`snfactory`) from OSM extracts and ADR restriction
overlays. ADR is the European agreement on carriage of dangerous goods by
road; tunnel codes B–E restrict which tunnels a hazmat load may use; the
full tunnel-code table is on the [conventions page](/docs/conventions).

## Trust model

- Every package manifest is signed with **ed25519**; every layer is
  content-addressed with **BLAKE3** hashes inside the signed manifest.
- Devices never trust the transport. A hostile CDN, mirror or gateway cannot
  alter what a device accepts. It can deny service, and it can replay an
  *older signed* channel state: there is no freshness guarantee by default
  (rollback-by-republish is a deliberate operator feature, so devices accept
  any signed version). A stale mirror can therefore hold devices on an older
  legitimate version; a signed index TTL can be layered on where that
  matters.
- The update channel is **dumb by design**: nothing but static files, servable
  by any web server or CDN, mirrorable into an air-gapped network with `rsync`,
  inspectable with `ls` and `tar`. No server-side logic participates in the
  trust model.

The public precedents for this shape (signed metadata over content-addressed
blobs on an untrusted store) are The Update Framework and OSTree.

## Differential over-the-air updates

Layers are content-addressed, so any two package versions that share a layer
share a blob. An unchanged multi-gigabyte routing tile tree costs no extra
disk on the channel and no extra download on the device. Devices poll the
signed channel index on their own schedule, fetch only the layers that changed,
verify, and apply with an **atomic swap**, so an interrupted update never leaves
a half-written territory.

```
snfactory build ──▶ signed package ──▶ snfactory publish ──▶ channel dir
                                                                │
                                        (any static host / CDN / rsync mirror)
                                                                │
                         gateway /territories  ◄────────────────┘
                                │
                          device pulls
                                │
        check_for_update ─▶ apply_update ─▶ verify ─▶ atomic swap
```

The gateway hop adds authentication, metering and HTTP niceties only; it is
deliberately outside the trust boundary. Devices verify every byte
themselves, so pointing them at a raw mirror instead changes nothing about
what they will accept.

## Fetching territories over the API

**Status, honestly:** the hosted gateway at `https://api.mapmap.ai` is live:
[sign up](/signup) for a key, or run the [self-host distro](/docs/self-host);
the endpoints below work identically against your own deployment. Base URL,
auth and conventions are defined in full on the
[conventions page](/docs/conventions).

Set up your shell once:

```sh
export BASE=https://api.mapmap.ai
export API_KEY=snk_your_key_here
```

Five endpoints serve the channel, behind the normal API-key auth
(`Authorization: Bearer` or `?api_key=`):

| Endpoint | Returns | Cache |
|---|---|---|
| `GET /territories` | The signed channel index (`index.json`, exact signed bytes) | `max-age=60` |
| `GET /territories/index.sig` | Detached base64 ed25519 signature over the index | `max-age=60` |
| `GET /territories/{id}/{version}/manifest` | The version's signed package manifest (exact bytes) | `max-age=3600` |
| `GET /territories/{id}/{version}/manifest.sig` | Detached manifest signature | `max-age=3600` |
| `GET /territories/{id}/{version}/layers/{addr}/{file}` | A content-addressed layer blob (tar + zstd) | `immutable`, 1 year |

Path parameters:

| Parameter | Required | Description |
|---|---|---|
| `id` | yes | Territory id, e.g. `uk` |
| `version` | yes | Published package version, e.g. `2.0.0` |
| `addr` | yes | Blob address: the first 16 lowercase hex characters of the layer's BLAKE3 hash, taken from the signed manifest |
| `file` | yes | Blob file name, e.g. `valhalla.tar.zst` |

List what is available:

```sh
curl -fsS "$BASE/territories" -H "Authorization: Bearer $API_KEY"
```

```json
{
  "format_version": 1,
  "generated_at": "2026-07-13T12:00:00Z",
  "territories": [
    {
      "id": "uk",
      "display_name": "United Kingdom",
      "latest_version": "2.0.0",
      "versions": [
        {
          "version": "2.0.0",
          "data_timestamp": "2026-07-01T00:00:00Z",
          "manifest_path": "territories/uk/2.0.0/manifest.json",
          "total_bytes": 123456789
        }
      ]
    }
  ]
}
```

`total_bytes` is the **installed** size — what the package occupies on the
device. Blobs on the wire are `tar + zstd`, but how much that saves depends
entirely on the layer: Valhalla tile trees and geocode indexes compress by
roughly 2-3x, while a PMTiles archive is already internally gzipped so zstd
is effectively a no-op on it. The same applies to `installedBytes` on an
`AvailableUpdate`. Never present either figure to a user as the cost of the
download, and never compare one against a metered allowance: for a PMTiles-
heavy territory the two are close, and for the rest you will overstate by
2-3x. The index and manifests are served byte-exact so the
detached signatures verify over the response body. Every response carries a
strong BLAKE3 ETag (`"b3-<hex>"`), and layer blobs support single-range
`Range`/`If-Range` requests, so an interrupted multi-gigabyte download
resumes instead of restarting.

Errors:

| Status | Meaning |
|---|---|
| `304` | Not modified; `If-None-Match` matched the ETag. Costs no download allowance |
| `402` | Monthly offline-download allowance exhausted (layer downloads only; see below) |
| `403` | Provisional key: territory downloads require a verified account |
| `404` | Unknown territory, version or blob, or no update channel configured on the deployment |
| `416` | Unsatisfiable `Range` |

### The download-allowance 402

Layer blobs are large (around a gigabyte each), so they are byte-metered
against your account's monthly offline-download allowance: 2 GiB a month by
default on the free tier; a plan or the SDK licence raises it (see
[pricing](/pricing)). Metadata (the index, signatures and manifests) is
never metered against it, and neither are `304` or `416` responses. When
serving a blob would exceed the allowance, the gateway refuses before
streaming any bytes:

```json
{
  "code": "download_allowance_exceeded",
  "allowance_mib": 8192,
  "used_mib": 2048,
  "message": "offline map download allowance exhausted; upgrade to a plan or the SDK licence for production downloads",
  "upgrade": "https://api.mapmap.ai/pricing"
}
```

This is a different 402 from the [x402 payment challenge](/docs/x402): the
x402 body is a machine-payable offer (`x402Version`, `accepts[]`; pay and
retry), while the download-allowance body above is a monthly cap with an
upgrade pointer, not payable per request. Both are plain `application/json`
rather than the problem envelope other errors use; the fields distinguish
them. The [conventions page](/docs/conventions) covers the error envelope
and the 402-vs-429 split in full.

## Coverage

Territory definitions span every continent: country packages across Europe
and North America (the US, Canada, Mexico and Russia among them), plus
continent-scale packages for Africa, Asia, South and Central America and
Australia–Oceania, and more European countries are landing steadily, so
this page deliberately gives no count. The live index at
`GET /territories` is the source of truth for what a given deployment
actually publishes, including versions and installed sizes.

## The hosted channel's verifying key

Every device pins the publishing factory's ed25519 **verifying key** (the
public half of the signing pair, 64 lowercase hex characters) and rejects
any package or channel index that does not verify against it. For the
hosted channel at `api.mapmap.ai`, that key is:

```
c2230696626a1ecbc61cf9ac141eabea5a3a1a15dab70d2acc110bec79108bcf
```

This is a **public** key: safe to commit, embed and publish. Bake it into
your app at build time; never fetch it at runtime from the same channel it
validates.

**Android**: a Gradle property surfaced as a `BuildConfig` field:

```kotlin
// app/build.gradle.kts
android {
  buildFeatures { buildConfig = true }
  defaultConfig {
    buildConfigField(
      "String", "MAPMAP_FACTORY_PUBKEY_HEX",
      "\"${providers.gradleProperty("mapmap.factoryPubkeyHex").get()}\"",
    )
  }
}
```

```kotlin
val store = TerritoryStore(
    rootDir = File(context.filesDir, "territories"),
    verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX,
)
```

**iOS**: a Swift constant compiled into the app:

```swift
enum MapMapTrust {
    /// The hosted channel's factory verifying key (public half).
    static let factoryPubkeyHex = "<the 64-hex key above>"
}

let store = TerritoryStore(rootDir: territoriesDir,
                           verifyingKeyHex: MapMapTrust.factoryPubkeyHex)
```

**Web** (no key to pin): the web SDK streams tiles over HTTPS from the
gateway and does not install signed packages in the browser; the
signed-package trust boundary is the on-device territory store.

[Self-host](/docs/self-host) operators pin their **own** public key
(`snfactory keygen` writes the pair; embed `snfactory.pub`'s hex), not the
hosted one.

## Device flow

The device side is `TerritoryStore` in the mobile SDKs (a
Kotlin/Swift wrapper over the core's `TerritoryManager`; see
[SDKs](/docs/sdks) and the [Android API reference](/docs/android)). You
supply a `LayerFetcher`: a callback
(`fetch(relPath, expectedBlake3, destPath)`, synchronous by contract,
called from a background thread) backed by your platform's HTTP stack
(OkHttp, URLSession) or a plain file copy from a mirror. The
`expectedBlake3` argument is advisory: the core re-verifies everything
itself.

Kotlin (`ai.mapmap:core`):

```kotlin
import ai.mapmap.territory.TerritoryStore
import uniffi.mapmap.LayerFetcher

val store = TerritoryStore(
    rootDir = File(context.filesDir, "territories"),
    verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX, // pinned at build time
)

// Your transport; the core decides what to fetch and verifies every byte.
val fetcher = object : LayerFetcher {
    override fun fetch(relPath: String, expectedBlake3: String, destPath: String) {
        download("$BASE/territories/$relPath", File(destPath)) // e.g. OkHttp, synchronous
    }
}

// 1. Fetch the signed index: GET /territories and /territories/index.sig.
// 2. Ask the store whether an installed territory has an update.
val update = store.checkForUpdate("uk", indexJson, indexSigBase64, fetcher)

// 3. null means the channel already agrees with the installed version.
//    Otherwise it carries the changed layers and their installed size.
if (update != null) {
    // update.installedBytes is UNCOMPRESSED — the disk the update adds,
    // not the metered bytes on the wire. See the note below.
    // applyUpdate is resumable across attempts.
    val result = store.applyUpdate(
        territoryId = "uk",
        newManifestJson = update.manifestJson,
        newManifestSigBase64 = update.manifestSig,
        fetcher = fetcher,
    )
    // result.previousVersion → result.territory.version
}
```

Swift (`MapMapKit`):

```swift
import MapMapKit

let store = TerritoryStore(rootDir: territoriesDir,
                           verifyingKeyHex: mapMapFactoryPubkeyHex) // pinned at build time

final class URLSessionFetcher: LayerFetcher, @unchecked Sendable {
    let baseURL: URL
    init(baseURL: URL) { self.baseURL = baseURL }
    func fetch(relPath: String, expectedBlake3: String, destPath: String) throws {
        // Synchronous by contract (called from a background thread):
        let data = try Data(contentsOf: baseURL.appendingPathComponent(relPath))
        try data.write(to: URL(fileURLWithPath: destPath), options: .atomic)
    }
}

let fetcher = URLSessionFetcher(baseURL: channelURL)
// indexJson / indexSig are the exact served bytes of index.json(.sig):
if let update = try await store.checkForUpdate(
    territoryId: "uk", indexJson: indexJson, indexSigBase64: indexSig, fetcher: fetcher
) {
    // update.installedBytes is UNCOMPRESSED — see the note below.
    // applyUpdate is resumable across attempts.
    let result = try await store.applyUpdate(
        territoryId: "uk", update: update, fetcher: fetcher)
    print("updated \(result.previousVersion) → \(result.territory.version)")
}
```

`checkForUpdate` verifies the index signature over the exact bytes,
compares `latest_version` with the installed version, then fetches and
verifies the remote manifest and plans the differential download. A channel
version *older* than the installed one is still offered: a rolled-back
channel is an instruction to downgrade. `applyUpdate` authenticates the
manifest before acting on any of it, stages the new version (unchanged
layers hardlinked from the current install; changed layers fetched, unpacked
and BLAKE3-verified against the signed manifest), runs full package
verification on the staged directory, and atomically swaps it in. A crash,
transport failure, hash mismatch or bad signature at any point leaves the
previous version installed and untouched.

## Publishing your own channel (self-host)

Self-hosted deployments sign with their own keys, so you choose who can
publish maps to your fleet. The channel directory is plain static files;
three commands take a built package live:

```sh
# 1. Build and sign the package.
snfactory build --territory uk --workdir /data/uk --signing-key /keys/snfactory.key

# 2. Publish into the channel directory the gateway/CDN serves.
snfactory publish \
  --package /data/uk/staging/uk \
  --channel-dir /srv/sn-channel \
  --signing-key /keys/snfactory.key

# 3. Belt and braces: verify the whole channel before it goes live.
snfactory channel-verify --channel-dir /srv/sn-channel --pubkey /keys/snfactory.pub
```

Publish is atomic per version (staged then renamed) and idempotent:
republishing the same version regenerates a byte-identical index. Point the
gateway at the directory with `SN_CHANNEL_DIR` and the `/territories`
endpoints above serve it. Rollback is republishing the previous index:
version directories are immutable and additive, so remove the bad version,
republish any still-present one, and devices downgrade on their next poll.

For an air-gapped mirror:

```sh
rsync -aH --delete /srv/sn-channel/ mirror:/srv/sn-channel/
```

`-H` preserves the hardlinks between versions so the mirror does not
balloon. Devices inside the air gap point their `LayerFetcher` at the
mirror; signatures verify identically because the trust anchor is the
factory public key baked into the app, not the host. Verify a mirror at any
time with the same `snfactory channel-verify` command.

The private signing key never leaves the factory host; the gateway and CDN
hold no secrets. Key rotation ships as an app update (new pinned public
key), then a channel republished with the new key.

## Deployment properties

- **Air-gap capable**: mirror the channel directory inside your network; no
  vendor callback is part of the trust model.
- **Auditable**: the channel layout is documented and normative; packages can
  be unpacked and inspected with standard tools.
- **Key custody**: self-hosted deployments sign with their own keys, so you
  choose who can publish maps to your fleet.

The full normative specification (channel layout, index determinism, key
rotation, threat table) ships with source access, available on request.

## Base map coverage

Territory render layers include ocean and land at every zoom, from the
factory's global-sources stage (OSM water polygons and Natural Earth), so
maps never show empty sea. Styling is a package layer of its own, so a
custom style ships and updates independently of the routing and render
data.

## Hosted routing coverage

The hosted API routes **worldwide** on a single planet-scale graph
covering every continent, built from a single-day OSM snapshot and
rebuilt monthly with automated regression gates (route-distance sanity
against the great circle, border seam audits, uncovered-landmass
audits). Continent-length journeys, border crossings and ferry legs run
on the same engine and the same key as a city hop. `GET /territories`
remains the source of truth for which offline packages exist.

## Next steps

- [File formats](/docs/formats): the channel-index JSON schema, the installed-package on-disk layout and the `.drive.jsonl` replay corpus
- [Self-host](/docs/self-host): run the gateway and channel on your own infrastructure with your own signing keys
- [SDKs](/docs/sdks): device-side integration on Android, iOS and web
- [Conventions](/docs/conventions): base URL, auth, error envelope, quotas and the ADR tunnel-code table
- [API reference](/docs/api-reference): every gateway endpoint
