Runbook: opening a reference zone
Opening a zone is the main recurring operational act of this project: manual, deliberate, repeated zone by zone. This runbook is the procedure, for production and for local development, which do not have the same prerequisites.
This is the reference dataset, not the routing graph. Two datasets, two grains, two calendars, never one command (ADR-049 §6):
| Reference (this runbook) | Routing (valhalla-routing-graph.md) | |
|---|---|---|
| Question | what is near this track? | can we go from A to B? |
| Grain | region (bretagne) |
country (france) |
| Store | osm / tourism PostGIS schemas |
valhalla-tiles Docker volume |
| Command | make provision <zone> |
make routing-build <slug> |
| Cadence | often, one zone at a time | rarely, per country |
Opening a zone cannot degrade routing, and a failed graph build cannot block an opening. The one link between them is an invariant, checked in one direction only: the routing perimeter must encompass the zone.
Symptoms
Reasons to run this:
- A region has no POI, accommodation or ford data, and
/api/healthreports it as not open. - Users see a route rendered display-only with an "out of zone" indication.
- An opened zone needs refreshing to pick up what OSM has gained since.
- You are setting up a development machine and need something in the index to work against.
Diagnosis
What is open, and whether the routing graph covers it:
curl -s https://<host>/api/health | jq '.deps.reference_data.zones'
{
"open": [{ "slug": "bretagne", "country": "france", "routable": true, "…": "…" }],
"routing_perimeter": ["france"],
"routing_containment": { "status": "ok", "uncovered": [] }
}
open: []— nothing is open. Every trip is "coverage unknown", never "out of zone".routing_containment.status: "violated"— a zone is open that the graph does not cover. Trips there are displayable but not routable. Build the missing country (make routing-build <country>); nothing derives one perimeter from the other, so this is checked, never maintained.
Procedure
make provision runs the provisioner service of the compose stack you are on: locally, your
dev stack; with make provision-recette <zone>, the local iso-prod recette stack. The
local-development specifics are in the next section.
In production, run the same steps on the VM as the deploy user, with btp-compose
instead of make (the provisioner image of the release tag the checkout is on):
btp-compose --profile provisioning run --rm provisioner bretagne
deploy/prod/compose.yaml attaches the provisioner to btp-shared and points its PG*
variables at the shared pg-reference, as the database owner (REFERENCE_DB_OWNER_* in the
prod env file, from Vault). The DataTourisme / OpenAgenda credentials come from the same file
(vault_datatourisme_*, vault_openagenda_api_key, openagenda_dataset); an empty one skips
its source. The app reads the result as reference_ro, which the Ansible shared_infra role
creates with SELECT on osm and tourism only (default privileges cover the tables the
provisioner creates later). The routing check reads the shared Valhalla volume, so step 1 is
make routing-publish from a workstation, not make routing-build on the VM.
1. Make sure the routing graph covers the zone
The opening is refused if it does not, before anything is downloaded, with the command
to run. bretagne needs france; Belgium, the Netherlands and Luxembourg are their own
country.
make routing-build france # hours, ~8 GB RAM, ~30 GB disk. See the routing runbook.
2. Open the zone
make provision bretagne # slug or display name
One zone per run, and the argument is mandatory — there is no cumulative selection and no selection file. What happens, in order: the DataTourisme flux is staged, the zone's Geofabrik extract is downloaded and imported into a staging schema of its own, names are resolved, the completeness gate refuses what it cannot complete, the OpenAgenda events export is imported (clipped to the zone), and the survivors of each source are promoted into the live tables in a single transaction.
Events come from two sources (ADR-051). DataTourisme and OpenAgenda both feed
tourism.events, each stamping its own source column; the runtime deduplicates the overlap
at read time (EventSourceRegistry). Both are optional and skipped gracefully when their
credentials are absent — OSM always provisions. The environment they read (mirrors the
DataTourisme pattern, all set on the provisioner service in compose.yaml):
| Source | Env | Notes |
|---|---|---|
| DataTourisme | DATATOURISME_FLUX_ID, DATATOURISME_APP_KEY |
national flux ZIP; feeds POIs, accommodations and events |
| OpenAgenda | OPENAGENDA_DATASET (+ optional OPENAGENDA_API_KEY) |
Opendatasoft dataset slug of the national JSONL export (e.g. evenements-publics-openagenda); events only |
OpenAgenda runs after the OSM step (it clips its national export to the zone geometry the OSM import produces) and before the DataTourisme promotion, so the DataTourisme metadata refresh counts its events in the live totals.
Opening a second zone keeps the first. Promotion is an INSERT ... SELECT restricted to
keys the live tables do not already hold — never a schema swap — so no other zone is exposed
to the run. Order does not matter; open them in whatever order users need.
Until a zone is opened, a route crossing it is out of zone: the frontend renders it display-only rather than re-routing it, because there is no reference data to enrich it with. That is a true statement now — coverage is the union of the opened zones, read from the registry.
3. Read the opening report
The console prints what the gate did:
Completeness gate
-----------------
118 name(s) resolved, of which 37 from the curated flux.
46 entry(ies) refused, 3 of them as ambiguous matches.
- no_usable_name_source: 43
- ambiguous_match: 3
Ranked by distance to the nearest cycle route in /data/zones/bretagne/rejected.tsv.
A long refusal list is a signal about the code, not a backlog of work. The resolvers
should absorb most of the volume; when refusals outnumber resolutions the command says so
explicitly. Before spending hours on manual corrections, check whether the fix belongs in
provisioner/src/NameResolver.php — a resolver improvement is retroactive across every zone
and survives a rebuild, a hand correction is neither.
4. Correct what is worth correcting
rejected.tsv is sorted by distance to the nearest signed cycle route, nearest first, so the
useful entries are at the top and you can stop whenever the distances stop being interesting.
cd .docker/osm/data/zones/bretagne
cp rejected.tsv override.tsv # keep the useful rows, fill in `name`
make provision-override bretagne
Keep override.tsv. Nothing in the system stores it: the corrections live only in the
live tables, so a database rebuilt from scratch loses every one whose file you did not keep.
That is the accepted price of "no correction table, no versioning" — and the reason a
resolver fix beats a hand correction whenever the pattern generalises.
The full loop, column by column, is in zone-opening-corrections.md.
5. Re-open a zone to refresh it
Same command:
make provision bretagne
Cheap, and worth understanding why:
- Re-analysed: nothing that this resolver version already decided. Entries it refused are
retried only after
NameResolver::VERSIONis bumped, which is what makes a resolver improvement reach zones already open. - Not re-analysed, not re-read, not rewritten: every row already present. The anti-join is on identity alone, so re-opening an unchanged zone inserts 0 rows and the report says "0 new entries". That is the proof the gate works, not a sign the run did nothing.
- Completed, never replaced: a row present with a NULL field can be filled by
COALESCE. An existing value is never overwritten. The single exception islast_seen_at, which is metadata. - Events are the exception to all of the above. They are perishable, not append-only, so
they are upsert-and-purged (
ON CONFLICT (id) DO UPDATE, thenDELETE … WHERE end_date < today), not anti-joined. An event whose dates moved is updated in place; a passed event is dropped. Opening a zone refreshes its events this way, and the standaloneevents-refreshcommand does the same for every open zone (ADR-051 §4, see events-refresh.md).
6. Idempotence, as it actually is
The idempotence is that of the PostGIS insertion step, not of the whole run. Re-running
make provision <zone> inserts 0 rows on the second pass only if all of these hold:
- the Geofabrik extract is still in the local cache (
.docker/osm/data/regions/), otherwise it is re-downloaded — hundreds of MB; - the 30-day TTL of the Wikidata cache has not expired, otherwise the enrichment queries Wikidata again for the expired Q-IDs;
- the DataTourisme flux is unchanged — and it is a national ZIP re-downloaded in full on every run, so this one is never free;
- likewise the OpenAgenda export is a national JSONL re-downloaded in full on every run,
and events are perishable, so a re-open is how upcoming events reach the index. You do not
need to re-open a zone just to refresh its events:
make events-refresh(ADR-051 §4, events-refresh.md) does that for every open zone without touching the append-only place tables.
Do not describe a re-opening as a no-op. It re-downloads, re-filters and re-imports into staging; what it does not do is write to the live tables.
7. What is not guaranteed
Say these out loud rather than discovering them:
- No staleness threshold.
/api/healthreports the index's age; it carries no verdict and must not grow one. Nothing refreshes these sources on a schedule, so any threshold would be permanently red. - Obsolescence is assumed. A closed campsite stays listed until someone notices. Total reliability of a listing is not reachable, and the person who books is the person who verifies — which is why the verification link (website, phone) is a tool, not decoration.
- A row removed from OSM stays in the database. That is the direct consequence of "never overwritten, never rewritten": a clipped extract cannot distinguish "deleted upstream" from "outside this extract", so nothing tries.
Procedure - local development
1. Do not open france
It is not even offered: whole-country entries left the reference registry (ADR-049 §1). Pick
the smallest zone that has what you need. Measured reference point: the merged
nord-pas-de-calais + rhone-alpes set (767 MB of extracts) filters to 191.6 MB and takes about
160 s of osm2pgsql (#880). A single small zone is a fraction of that.
| Working on | Suggested zone | Why |
|---|---|---|
| Accommodations, POI, the pricing heuristic | corse (32 MB) |
small, and dense enough in campsites and gîtes |
| Cycle-route indicators, ferries, fords | bretagne (307 MB) |
signed véloroutes, real ferry crossings, coastline |
| Locality labels, admin boundaries | any region | communes come with every extract |
| The provisioner itself | mayotte (10 MB) |
fastest full round-trip through the whole pipeline |
2. Skip the routing graph
A dev machine has an empty routing volume, which the containment check reads as an observed empty perimeter and refuses. Building the national graph to work on accommodations is hours and ~30 GB, so there is an explicit way out:
make provision corse -- --allow-unrouted-zone
The -- separator is required: a bare make provision corse --allow-unrouted-zone lets
make claim the flag as one of its own options and fails with
unrecognized option '--allow-unrouted-zone'. With --, make stops parsing
options and the target forwards the flag to the container. Calling the container directly
(docker compose --profile provisioning run --rm provisioner corse --allow-unrouted-zone)
works too.
Trips in that zone will not be routable — everything else works. Do not improvise the
alternative of dropping a placeholder extract into the routing volume:
build-routing-graph.sh skips downloading an extract that is already present, so the next
real build would silently build from your placeholder.
3. Provision from the main checkout, not from a worktree
docker compose derives its project name from the directory, so a worktree gets its own
containers, its own database and its own volumes. Provisioning from a worktree fills a
database no other stack reads, and the pwa_node_modules volume is empty there too.
Open zones from the main checkout. If you need a worktree's provisioner code against the
main stack's database, run the container by hand against that network rather than through the
worktree's compose project, the same pattern the PHPUnit recipes in
.claude/local-qa.md use.
4. Start over
Nothing here is precious except your override.tsv files:
# Forget the open zones and drop the provisioner caches; the next opening recreates the
# `provisioner` schema (the entrypoint applies reference_schema.sql, and migrate does in dev).
docker compose exec database psql -U app -d bike_trip_planner \
-c 'TRUNCATE osm.zones, osm.routing_perimeter; DROP SCHEMA IF EXISTS provisioner CASCADE;'
docker compose exec php bin/console doctrine:migrations:migrate --no-interaction
# Optional, and expensive to undo: drop the cached extracts too (re-downloads on next run).
rm -rf .docker/osm/data/regions/*
What it costs: dropping the provisioner schema discards the Wikidata and name-resolution
caches, so the next opening re-queries Wikidata for every Q-ID and re-runs the resolver over
every anonymous row. Keeping the extracts saves the download; keeping the caches saves the
network.
Verification and follow-up
curl -s <host>/api/health | jq '.deps.reference_data.zones'— the zone appears inopenwithroutable: true, androuting_containment.statusisok.jq '.deps.reference_data.osm.feature_counts'— the counts grew.jq '.deps.reference_data.osm.rejections'— what the gate refused, and how much of it the curated flux named.- Plan a trip crossing the new zone: accommodations and POI appear, and it is no longer display-only.
- If refusals outnumbered resolutions, open an issue against the resolvers rather than a correction task against the data.
References
- ADR-049 — zone opening, transactional promotion, import-time completeness, append-only storage
- ADR-040 — the local-first reference index this fills
- ADR-041 — failure isolation between sources
- zone-opening-corrections.md — the report and the manual correction loop
- valhalla-routing-graph.md — the other dataset