SDK Integration Patterns for Indoor Maps

The client SDK is the last link in the delivery path, and the one most exposed to failure: it consumes a precompiled routing graph on a phone or kiosk that may be offline, mid-route, or running a build six versions behind the map. This technique sits inside the Production-Ready Indoor Map Deployment lifecycle, immediately downstream of the artifact registry and CDN — once a validated floor-level graph is published, the SDK has to negotiate the right version, hydrate it into a native routing engine without blocking the UI thread, and degrade predictably when positioning or connectivity drops. Get the integration contract wrong and a perfectly validated artifact still produces silent rendering failures, routing deadlocks, and abandoned navigation sessions.

The Problem: A Validated Graph the Client Still Mishandles

By the time a routing graph reaches the SDK it has already passed schema validation and topology gating, so the failures here are not data-quality failures — they are contract and lifecycle failures at the boundary between a versioned backend artifact and a stateful client. Three symptoms dominate production incident reports.

First, silent hydration failures. The SDK fetches a graph whose schema_revision its build does not understand, deserializes it without error, and then returns “no route found” for every query because the edge properties it expects are named differently. Nothing crashes; navigation simply never works on that device cohort.

Second, version drift mid-session. A user starts walking a route, a facilities team promotes a new floor-level revision, and the next graph fetch silently swaps the adjacency list under the running engine — the blue dot teleports, or the engine recomputes onto an edge that no longer connects to the user’s position.

Third, fallback that fails open instead of closed. When indoor positioning weakens or the network drops mid-fetch, a naive SDK throws, the UI shows a blank canvas, and the user is stranded in a corridor with no guidance. A correct SDK fails closed — it keeps serving the last-known-good graph and an immutable emergency-egress layer rather than nothing.

The job of an integration pattern is to make all three impossible by contract: a strict payload envelope, version negotiation on every fetch, atomic graph hydration, and a defined degradation ladder.

Prerequisites & Dependencies

Before wiring an SDK against the delivery layer, the following must already be in place:

  • A validated artifact contract. The SDK should only ever consume artifacts that passed the JSON Schema Design for Indoor Maps contract and the gates in CI Gating for Map Updates. The client re-validates a thin envelope, not the full topology — it trusts the gate for deep correctness.
  • The canonical GeoJSON envelope. Every payload is a GeoJSON FeatureCollection with a top-level metadata block carrying map_version, schema_revision, building_id, floor_level, topology_hash, and coordinate_system. The SDK keys all of its caching and version logic off that metadata, so the producing pipeline must already emit it.
  • A content-addressed version tag. Version negotiation reuses the topology_hash as the ETag; this is the same hash that drives Cache Invalidation Strategies, so the SDK and the CDN agree on identity for free.
  • A consistent coordinate frame. Geometry arrives in the building’s local Cartesian Indoor Coordinate Reference System with the origin on a survey control point and Z encoding floor level. The SDK never re-projects; it renders and routes in the frame it is handed.
  • Libraries. On the server side, pydantic (2.x) for envelope validation and redis-py (5.x) for the invalidation channel. On the client side, a native routing module (C++/Rust exposed through a bridge) plus requests/fetch for conditional GETs and a local SQLite or in-memory store for the offline graph.

How It Works: Three Tiers and a Negotiation Contract

The integration spans three tiers, and the SDK’s correctness depends on each one honouring a single contract at its boundary:

  1. Spatial processing tier — PostGIS / GDAL / NetworkX compile geometry into a directed routing graph and serialize the canonical envelope. This tier owns topology truth.
  2. Middleware tier — a Python service that validates the envelope, negotiates versions via ETag/If-None-Match, and acts as a circuit breaker: a malformed or version-incompatible payload is rejected here, never forwarded to a device.
  3. Client SDK tier — mobile and kiosk clients that hydrate the graph into a native engine, cache it for offline use, and execute real-time wayfinding.

Data flows unidirectionally for map delivery, but the client also pushes telemetry (positioning quality, route-failure rate) back upstream, which is what lets Rollback Triggers & Versioning auto-revert when a release degrades navigation. The negotiation contract is the load-bearing piece: the client sends its current topology_hash as If-None-Match; the middleware answers 304 Not Modified when the hash matches (zero payload transfer) or 200 with a fresh envelope and a new X-Map-Version header when it does not. The client only swaps its in-memory engine at a safe decision point, never mid-step.

SDK version negotiation and the fail-closed degradation ladder The client SDK issues a conditional GET for the latest floor graph with its current topology_hash in If-None-Match. The middleware validates the thin envelope and compares the hash against the latest promoted artifact, then branches into three responses. When the hash matches it returns 304 Not Modified and the client keeps serving its cached engine with no payload transfer. When the hash differs it returns 200 with a fresh GeoJSON FeatureCollection and an X-Map-Version header, and the client hydrates the new graph into a native staging slot before atomically promoting the engine reference. When the client's declared schema revision is unsupported it returns 409 SCHEMA_INCOMPATIBLE and the client rejects the build and keeps its cached engine. Independently, any sync or positioning failure walks a fail-closed ladder: latest synced graph, then last-known-good cache, then a pre-baked immutable emergency-egress graph, then a 2D Routing Unavailable safe state. Version negotiation on every fetch, with a fail-closed fallback ladder Client SDK mobile / kiosk · native engine Middleware version negotiator · circuit breaker GET /maps/{building}/{floor}/latest If-None-Match: topology_hash · X-Schema-Revision validate thin envelope compare client hash · check schema alt [ hash matches ] 304 Not Modified serve cached engine zero payload transfer [ hash differs ] 200 · FeatureCollection + X-Map-Version hydrate native engine atomically stage → promote on success [ client schema unsupported ] 409 SCHEMA_INCOMPATIBLE reject build · keep cached engine Degradation ladder — fail closed latest synced graph fresh from the 200 response fetch throws last-known-good cache last valid topology_hash cache empty emergency-egress graph pre-baked · immutable egress missing Routing Unavailable 2D safe state · never blank

Step-by-Step Implementation

The integration has four ordered steps: validate the envelope, negotiate the version, hydrate the engine, then degrade safely. The first two run in the middleware (Python), the last two in the client (TypeScript over a native bridge).

Step 1: Validate the canonical envelope at the boundary

SDKs fail silently on malformed spatial data — missing edge weights, an unexpected schema_revision, or orphaned node references make a wayfinding engine return suboptimal paths or none at all. Validate the GeoJSON envelope at the middleware boundary before a single byte reaches a device, and reject incompatible schema revisions outright rather than letting the client guess.

import logging
from typing import Any

from pydantic import BaseModel, Field, ValidationError, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

SUPPORTED_SCHEMA_REVISIONS: frozenset[str] = frozenset({"v1.2.0", "v1.3.0"})


class EnvelopeMetadata(BaseModel):
    map_version: str
    schema_revision: str
    building_id: str = Field(..., min_length=1)
    floor_level: int
    topology_hash: str = Field(..., min_length=8)
    coordinate_system: str

    @field_validator("schema_revision")
    @classmethod
    def revision_must_be_supported(cls, v: str) -> str:
        if v not in SUPPORTED_SCHEMA_REVISIONS:
            raise ValueError(f"unsupported schema_revision: {v}")
        return v


class MapEnvelope(BaseModel):
    type: str = Field(..., pattern="^FeatureCollection$")
    metadata: EnvelopeMetadata
    features: list[dict[str, Any]]


def validate_envelope(raw: dict[str, Any]) -> MapEnvelope:
    """Validate an incoming GeoJSON map envelope before it reaches a client SDK."""
    try:
        envelope = MapEnvelope(**raw)
    except ValidationError as exc:
        logger.error("envelope rejected at boundary: %s", exc)
        raise
    logger.info(
        "envelope ok: %s floor=%d hash=%s",
        envelope.metadata.building_id,
        envelope.metadata.floor_level,
        envelope.metadata.topology_hash[:12],
    )
    return envelope

Step 2: Negotiate the version in middleware

The middleware compares the client’s submitted hash against the latest promoted artifact and answers with a 304 (cheap no-op) or a fresh envelope. This is where a circuit breaker lives: if the requested schema revision is incompatible with the client’s declared build, the middleware returns a structured error instead of a graph the client cannot parse.

import logging
from dataclasses import dataclass
from typing import Optional

logger = logging.getLogger(__name__)


@dataclass
class NegotiationResult:
    status: int                       # 200, 304, or 409
    envelope: Optional[MapEnvelope]   # populated only on 200
    map_version: Optional[str]
    detail: Optional[str] = None


def negotiate_version(
    building_id: str,
    floor_level: int,
    client_hash: Optional[str],
    client_schema_revision: str,
    latest_envelope: MapEnvelope,
) -> NegotiationResult:
    """Resolve which routing graph (if any) the client should receive."""
    meta = latest_envelope.metadata
    if client_schema_revision not in SUPPORTED_SCHEMA_REVISIONS:
        logger.warning("client schema %s incompatible for %s", client_schema_revision, building_id)
        return NegotiationResult(409, None, None, detail="SCHEMA_INCOMPATIBLE")

    if client_hash == meta.topology_hash:
        logger.info("304 for %s/%d (client current)", building_id, floor_level)
        return NegotiationResult(304, None, meta.map_version)

    logger.info("200 for %s/%d -> v%s", building_id, floor_level, meta.map_version)
    return NegotiationResult(200, latest_envelope, meta.map_version)

Step 3: Hydrate the native engine atomically

On the client, a fresh envelope must be loaded into the native routing engine before it replaces the live one, so a half-loaded graph never serves a query. In React Native this means bridging a native C++/Rust routing library through promises so graph hydration never blocks the JS thread — the full bridge contract is detailed in Integrating Indoor Maps with React Native SDKs. The pattern below builds the new engine into a staging slot and swaps the reference only on success.

interface MapEnvelope {
  type: "FeatureCollection";
  metadata: { mapVersion: string; topologyHash: string; floorLevel: number };
  features: Array<Record<string, unknown>>;
}

interface WayfindingEngine {
  topologyHash: string;
  computeRoute: (start: string, end: string) => Promise<string[]>;
}

export async function syncEngine(
  endpoint: string,
  buildingId: string,
  floorLevel: number,
  current?: WayfindingEngine,
): Promise<WayfindingEngine> {
  const headers: Record<string, string> = { "X-Schema-Revision": "v1.3.0" };
  if (current) headers["If-None-Match"] = current.topologyHash;

  const res = await fetch(`${endpoint}/maps/${buildingId}/${floorLevel}/latest`, { headers });

  if (res.status === 304 && current) return current;          // already current
  if (res.status === 409) throw new Error("SCHEMA_INCOMPATIBLE: client build is stale");
  if (!res.ok) throw new Error(`map fetch failed: ${res.status}`);

  const envelope: MapEnvelope = await res.json();
  // Build into a staging slot; only promote on success so a half-loaded graph never serves.
  const staged = await NativeModules.WayfindingBridge.loadGraph(envelope);
  if (!staged) throw new Error("native hydration failed");

  return {
    topologyHash: envelope.metadata.topologyHash,
    computeRoute: (s, e) => NativeModules.WayfindingBridge.computePath(s, e),
  };
}

Step 4: Degrade closed when sync or positioning fails

When a fetch throws or indoor positioning drops below a confidence threshold, the SDK must fall back through a defined ladder rather than blanking out. The order is: the freshly synced graph, then the last-known-good cached graph, then a pre-baked immutable emergency-egress graph, and only then a 2D “Routing Unavailable” state. This is the same graceful-degradation contract used by Fallback Routing Architectures.

import logging
from typing import Callable, Optional

logger = logging.getLogger(__name__)

GraphLoader = Callable[[], Optional[MapEnvelope]]


def resolve_active_graph(
    fetch_latest: GraphLoader,
    last_known_good: GraphLoader,
    emergency_egress: GraphLoader,
) -> MapEnvelope:
    """Return the best available routing graph, failing closed rather than open."""
    for stage, loader in (
        ("latest", fetch_latest),
        ("last_known_good", last_known_good),
        ("emergency_egress", emergency_egress),
    ):
        try:
            graph = loader()
        except (OSError, ValidationError) as exc:
            logger.warning("graph stage %s unavailable: %s", stage, exc)
            continue
        if graph is not None:
            logger.info("serving graph from stage=%s", stage)
            return graph
    raise RuntimeError("ROUTING_UNAVAILABLE: no graph at any degradation stage")

Edge Cases & Gotchas

Symptom Root cause Diagnostic Remediation
SDK returns “no route” on a fully mapped floor Client build predates the artifact’s schema_revision; edge properties renamed Compare X-Schema-Revision request header to artifact schema_revision Negotiate at the middleware and return 409 SCHEMA_INCOMPATIBLE instead of a graph the client misreads
Blue dot teleports mid-route Engine reference swapped while a route was active Log the topology_hash at route start vs. each recompute Stage the new engine and promote only at the next decision point, never mid-step
Blank canvas after a dropped fetch SDK failed open — threw instead of falling back Inspect for an uncaught rejection in syncEngine Route every failure through the degradation ladder so the last-known-good graph keeps serving
Cache stampede on publish Every device refetches the same new floor at once Watch origin request rate spike at promotion time Add exponential backoff with jitter and serve 304 on If-None-Match matches
Disconnected components in the graph Incomplete BIM export produced orphaned nodes networkx.number_connected_components() on the edge list before promotion Block the artifact at the gate; isolate components with < 3 edges for manual review

The unifying rule: the SDK trusts the gate for topology correctness but never trusts the network or the calling build — it negotiates version on every fetch and degrades closed on every failure.

Validation Output

Confirm the contract with explicit assertions rather than a visual check. A correct integration yields a no-op 304 when the client is current, a fresh graph when it is not, and a clean rejection when the schema is incompatible:

latest = validate_envelope(raw_envelope)            # passes the boundary
current_hash = latest.metadata.topology_hash

# Client already holds the latest hash -> 304, no transfer.
r1 = negotiate_version("BLDG-04", 3, current_hash, "v1.3.0", latest)
assert r1.status == 304 and r1.envelope is None

# Client holds a stale hash -> 200 with the fresh envelope.
r2 = negotiate_version("BLDG-04", 3, "sha256:stale0000", "v1.3.0", latest)
assert r2.status == 200 and r2.envelope is latest

# Client build too old for the schema -> 409, never a graph.
r3 = negotiate_version("BLDG-04", 3, None, "v1.0.0", latest)
assert r3.status == 409 and r3.envelope is None

The incorrect pattern to watch for is negotiate_version returning a 200 with a populated envelope to a client on an unsupported schema_revision. That means the circuit breaker is open, the device will deserialize fields it does not understand, and you will see the “no route on a mapped floor” symptom in the field rather than at the boundary.

Performance & Scale Notes

  • 304 is the budget. Conditional GETs keyed on topology_hash mean an unchanged floor costs a single round trip with no body. Across a kiosk fleet that polls every minute, serving 304 instead of the full envelope is the difference between kilobytes and tens of megabytes per minute.
  • Hydration is the latency cost. Deserializing and indexing a large floor’s graph into the native engine is O(V + E); keep it off the UI thread and stage it so a 200 ms hydration never freezes guidance. For multi-floor buildings, hydrate only the active and adjacent floors and lazy-load the rest.
  • Delta payloads, not full dumps. When only a corridor changed, fetch a delta of modified nodes and edges rather than the whole floor — typically two to three orders of magnitude smaller, which matters when a phone hands off between Wi-Fi and cellular mid-route.
  • Bounded local history. Keep the last few topology_hash graphs on-device so a fast revert (or a mid-session reconcile) is a pointer swap, not a refetch — the same bounded buffer that Rollback Triggers & Versioning relies on.
  • Observability. Instrument the bridge with engine_hydration_ms, negotiation_304_ratio, and degradation_stage counters. A rising degradation_stage distribution is the earliest signal that a release is failing in the field, and it is the telemetry that drives auto-rollback.

Frequently Asked Questions

Why negotiate with topology_hash instead of map_version?

Because the hash is content-addressed: identical geometry always produces the same ETag, so a rebuild that changes nothing is a free 304, and any real change produces a new tag the client must fetch. A map_version string is human-meaningful but non-deterministic across rebuilds — two builds of the same graph can carry different versions, which breaks the If-None-Match round trip and forces needless transfers. The SDK keys negotiation on the hash and surfaces map_version only for display and telemetry.

How do I stop a map swap from corrupting an in-progress route?

Never replace the live engine reference mid-step. Hydrate the new graph into a staging slot, and promote it only at a safe decision point — when the user reaches the next node or finishes the current segment. An in-progress route continues on the graph it started with and reconciles to the new topology_hash at the next junction, so the blue dot never teleports onto an edge that no longer connects to the user’s position.

What should the SDK do when indoor positioning or the network drops mid-fetch?

Fail closed, not open. Walk the degradation ladder: the freshly synced graph, then the last-known-good cached graph, then a pre-baked immutable emergency-egress graph, then a 2D “Routing Unavailable” state. The SDK should never throw an unhandled error that blanks the canvas; a user standing in a corridor needs the last valid guidance and the egress layer far more than a perfectly fresh map.

Where should schema validation run — middleware or client?

Deep topology validation runs once, at the gate, before promotion. The middleware re-validates only the thin envelope (type, metadata, supported schema_revision) and rejects incompatible builds with a 409. The client trusts the gate for correctness and checks only that the topology_hash it received matches what it hydrated. Re-validating the full graph on-device wastes battery and duplicates work the gate already guarantees.

This page is part of the Production-Ready Indoor Map Deployment reference; the artifacts it consumes are gated by CI Gating for Map Updates.