Versioning Indoor Map APIs Without Breaking Clients
The migration half of JSON Schema Design for Indoor Maps. Designing a good schema is the easy part; changing one that a fleet of mobile clients already depends on is where indoor map APIs actually get broken.
Breaking Is a Property of the Pair
A schema change is not breaking or non-breaking on its own — it is breaking for a client written a particular way. Adding an enum value is the canonical case: harmless for a client with a default branch, fatal for one that switches exhaustively.
That has a practical consequence. The client contract has to be written down and enforced, because it is what makes additive changes safe:
- Ignore unknown fields. Do not fail on properties you were not expecting.
- Handle unknown enum values. Every switch on
space_classorcategoryneeds a default that degrades sensibly — usually rendering the feature generically rather than dropping it. - Do not depend on field order or on absent fields.
An SDK that follows those three rules can absorb every additive change without a release. One that does not turns every schema addition into a coordinated migration.
Migrations Take Months
The measured curve is the argument. Twenty-eight weeks to move 99% of traffic from v2 to v3 is normal for a mobile fleet, because adoption is set by app-store update cadence and by users who do not update. Nothing in the API changes that.
So the dual-serving state is not a transitional inconvenience — it is where the service lives for most of a year, and it should be designed for. That means the validator can compile more than one schema version, the storage layer can serve either shape, and the version in use is a dimension on every metric so the two populations can be compared.
Retirement is then driven by measurement rather than by a date: when v2 traffic falls below a threshold, and the remaining clients have been identified, v2 is withdrawn. Announcing a cut-over date instead simply relocates the outage to that date.
Minimal Working Example
import logging
from dataclasses import dataclass
from jsonschema import Draft202012Validator, ValidationError
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SchemaRegistry:
"""Compiles every supported version so both can be served during a migration."""
schemas: dict[str, dict]
def __post_init__(self) -> None:
object.__setattr__(self, "_compiled",
{v: Draft202012Validator(s) for v, s in self.schemas.items()})
def supported(self) -> list[str]:
return sorted(self.schemas)
def validate(self, envelope: dict) -> str:
"""Validate against the version the envelope declares. Never guess a version."""
declared = envelope.get("schema_version")
if declared is None:
raise ValidationError("envelope declares no schema_version")
compiled = getattr(self, "_compiled")
if declared not in compiled:
raise ValidationError(
f"schema_version {declared!r} is not supported "
f"(have {', '.join(self.supported())})")
errors = sorted(compiled[declared].iter_errors(envelope), key=lambda e: e.path)
if errors:
first = errors[0]
pointer = "/" + "/".join(str(p) for p in first.absolute_path)
raise ValidationError(f"{pointer}: {first.message}")
return declared
def migrate_v2_to_v3(envelope: dict) -> dict:
"""Forward-migrate an older envelope so one storage shape can serve both versions."""
if envelope.get("schema_version") != "2":
return envelope
out = dict(envelope)
out["schema_version"] = "3"
for f in out.get("features", []):
props = f.setdefault("properties", {})
# v3 split the v2 `accessible` boolean into explicit attributes
if "accessible" in props:
props["step_free"] = bool(props.pop("accessible"))
props.setdefault("min_clear_width_m", None)
logger.info("migrated %d feature(s) from v2 to v3", len(out.get("features", [])))
return out
Forward-migrating on read is what keeps the storage layer from holding two shapes. An envelope arrives as v2, is migrated to v3 in memory, and is stored once; serving a v2 client then means projecting v3 back down, which is a smaller and better-tested piece of code than maintaining two parallel stores.
Common Errors & Fixes
A client crashes after an additive change. It switched exhaustively on an enum. The change was correct; the client contract was not enforced. Publishing a conformance test with the SDK — one that feeds it an envelope containing an unknown enum value and asserts it does not crash — turns the contract into something checkable.
The version is inferred rather than declared. Guessing a version from the shape of the payload
works until two versions have the same shape for a particular building. schema_version is
mandatory, and an envelope without one is rejected.
Retirement is announced and traffic does not move. Announcements do not update apps. Identify the remaining clients by version and reach the teams that own them; where the long tail is end users on old app versions, retirement means deciding to break them, which is a product decision rather than an engineering one.
Two versions drift apart. The v2 projection stops being maintained and quietly starts returning different data. Run the same conformance suite against both served versions in CI, so a v2 regression fails the build for as long as v2 is served.
Integration Point
Version negotiation sits at the API boundary, in front of the schema validation described in JSON Schema Design for Indoor Maps. Producers declare a version and are validated against it; consumers request one and are served a projection of the stored shape.
The version in use belongs on every metric emitted by
wayfinding API observability, for the same reason
map.version does: without it, a regression that only affects one schema version is invisible in
the aggregate, and the migration cannot be measured — which means it cannot be finished.
Frequently Asked Questions
Should the version live in the URL or in the payload?
In the payload for the envelope, and optionally in the URL for the API surface — they are different things. The envelope’s schema_version describes the shape of that document and travels with it into storage, caches and archives, which a URL cannot do. An API path version describes the request and response contract of the endpoints themselves. Most indoor map stacks need the first and can often avoid the second, because endpoint shapes change far less often than the envelope inside them.
How do I find out which clients are on an old version?
Make the version a metric dimension and the client identifier a span attribute. The metric tells you the shape of the migration — what proportion of traffic is still on v2, and whether it is falling — while the sampled traces let you identify which SDK build, which integration or which tenant is responsible for the remainder. Without the dimension you can see only an aggregate and cannot tell a stalled migration from a slow one; without the traces you know the migration has stalled and not who to talk to.
Is a breaking change ever justified?
Yes, when the alternative is carrying a defect indefinitely, and it is a product decision rather than an engineering one. A field whose meaning was wrong — a width in centimetres declared as metres, an accessibility flag that conflated two conditions — is worse to preserve than to break, because every consumer that reads it is making a wrong decision. What makes it justifiable is doing it deliberately: a new version, a long dual period, an explicit list of affected clients, and someone accountable for the users who will be broken at retirement.
Related
- JSON Schema Design for Indoor Maps — the schema this page evolves, and the validation contract around it.
- Designing JSON Schemas for Indoor Map APIs — the field-level design decisions that make migrations easier or harder.
- Wayfinding API Observability — where the version dimension makes a migration measurable.
This page is a companion to JSON Schema Design for Indoor Maps, part of the Production-Ready Indoor Map Deployment section.