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

Whether a schema change breaks clients depends on the clients A table of six changes paired with client behaviour. Adding an optional field is safe when clients ignore unknown fields. Adding an enum value is unsafe for a client that switches with no default branch and safe for one that has a default. Removing an optional field breaks a client that reads it unconditionally. Loosening a numeric range is safe for a client that validates its own inputs. A rename with an alias period is safe when clients read either name. A change is breaking only in combination with a client Change Client behaviour Safe? Add an optional field ignores unknown fields ● yes Add an enum value △ switch with no default △ no Add an enum value ● switch with a default ● yes Remove an optional field △ reads it unconditionally △ no Loosen a numeric range ● validates its own inputs ● yes Rename with an alias period reads either name ● yes Whether a change is breaking depends on how clients were written, not on the schema alone.

The same change appears twice with opposite answers. Adding an enum value is the canonical example — which is why the client contract has to say “handle unknown values” explicitly.

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_class or category needs 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

How long a schema migration actually takes Two mirrored curves over twenty-eight weeks. Traffic on version 2 falls from 100 percent to 51 percent by week eight, 14 percent by week sixteen and 0.8 percent by week twenty-eight. Version 3 traffic rises correspondingly. Reaching the one percent retirement threshold takes about seven months, because client adoption is limited by app store update cycles rather than by anything the API controls. Half a year is a normal migration, not a slow one 0 25 50 75 100 0 7 14 21 28 weeks since v3 was published share of requests (%) v2 traffic (%) v3 traffic (%) 28 weeks to reach the 1% retirement threshold

Seven months, and none of it is under your control. Client adoption is set by app-update cadence — which is the argument for planning a long dual period rather than a cut-over date.

The three states of a schema migration Three states. The service serves version 2 only, to all producers and consumers. When version 3 is published it enters a dual state where both are served. Only when version 2 traffic falls below one percent is version 2 retired and the service moves to version 3 only. From the dual state, version 3 can also be withdrawn, returning to version 2 only. Three states, and the middle one is not temporary v3 published v2 traffic < 1% v3 withdrawn v2 only all producers, all consumers Dual v2 + v3 both served v3 only v2 retired

The dual state is where a migration lives, and it lives there for months. Designing for a permanent dual state and retiring on measured traffic beats announcing a cut-over date.

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.

This page is a companion to JSON Schema Design for Indoor Maps, part of the Production-Ready Indoor Map Deployment section.