Designing Conflict-Free Identifiers for Offline Capture

Everything about offline sync depends on identity, and identity has to be minted where the observation happens — which rules out every scheme that needs a server to agree. This page is a focused companion to offline field collection and sync reconciliation.

Concept & Context

An identifier in a field workflow is not only a database key. It is written in a field book, spoken over a radio, photographed on a whiteboard beside the feature, and referenced in a sketch. When a sync process renumbers it, all of those links break at once, and the breakage is discovered weeks later when someone tries to match a photograph to a parcel.

That constraint — the identifier the crew recorded must be the identifier the feature keeps — is what disqualifies the obvious approach. A database sequence cannot be consulted from a valley with no signal, and any scheme that mints a temporary identifier for later replacement is a renumbering scheme with extra steps.

Four schemes come up in practice. Two of them fail offline, and the failure is silent in both cases.

How a sequence collides when nobody is watching A sequence in which two disconnected crews each ask their local copy for the next identifier, both receive the same number, and the collision only becomes visible when both packages reach the server weeks later. Crew east Crew west Server next id → 4471 next id → 4471 sync: parcel 4471 sync: a different parcel 4471 Renumbering on arrival fixes the database and breaks every photo, sketch and note referencing the original.

Core Algorithmic Pipeline

  1. Eliminate coordinated schemes. A database sequence and a central allocator both require connectivity at capture time.
  2. Decide on human readability. If crews quote identifiers aloud, a 36-character UUID is a real ergonomic cost; if only machines read them, it is free.
  3. Add the new column beside the existing key, populate it for the whole layer, and index it.
  4. Migrate consumers one at a time — joins, exports, published services — while both keys work.
  5. Demote the legacy key to a non-authoritative column rather than dropping it, so external references keep resolving.

The four candidate schemes:

Scheme Offline-safe Human-quotable Fails when
Database sequence No Yes Two devices both take the next number
Per-device integer block Partly Yes A block is exhausted, or a re-imaged device reuses one
UUIDv4 Yes No Nothing, but crews cannot read it aloud reliably
Crew-prefixed ULID Yes Yes The prefix registry is not maintained
What each part of a crew-prefixed identifier is for Three bands describing the identifier: the crew prefix gives provenance at a glance, the millisecond timestamp makes identifiers sort by creation time, and the random suffix provides collision resistance without coordination. PROVENANCE EAST — the crew that minted it ORDERING 7K3M2P9Q — 48-bit millisecond stamp COLLISION RESISTANCE 4R — 30 random bits, no coordination then then The alphabet omits I, L, O and U, so nothing in it can be misheard over a radio or misread in a field book.

Working Implementation

"""Mint, validate and migrate offline-safe feature identifiers."""
from __future__ import annotations

import os
import re
import secrets
import time

# Crockford base32: no I, L, O or U, so 1/I and 0/O cannot be misread or misheard.
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
ID_RE = re.compile(r"^[A-Z]{2,8}-[0-9A-HJKMNP-TV-Z]{16}$")


def _encode(value: int, length: int) -> str:
    out = []
    for _ in range(length):
        out.append(ALPHABET[value & 31])
        value >>= 5
    return "".join(reversed(out))


def mint(crew_prefix: str, when_ms: int | None = None) -> str:
    """Crew-prefixed ULID: 48-bit millisecond timestamp + 32 bits of randomness.

    Sortable by creation time, safe to mint on a disconnected device, and short
    enough to read over a radio without mistakes.
    """
    if not re.fullmatch(r"[A-Z]{2,8}", crew_prefix):
        raise ValueError(f"crew prefix {crew_prefix!r} must be 2-8 uppercase letters")

    ms = when_ms if when_ms is not None else int(time.time() * 1000)
    stamp = _encode(ms, 10)                     # 50 bits of capacity, 48 used
    entropy = _encode(secrets.randbits(30), 6)
    return f"{crew_prefix}-{stamp}{entropy}"


def is_valid(identifier: str) -> bool:
    return bool(ID_RE.fullmatch(identifier))


def minted_at_ms(identifier: str) -> int:
    """Recover the creation timestamp — useful for ordering an operation log."""
    body = identifier.split("-", 1)[1][:10]
    value = 0
    for ch in body:
        value = (value << 5) | ALPHABET.index(ch)
    return value

Migrating a layer that already uses integers is a schema change plus a backfill, and it is deliberately additive:

-- 1. Add beside the existing key; nothing breaks yet.
ALTER TABLE parcels ADD COLUMN parcel_uid text;

-- 2. Backfill deterministically, so a repeated migration is a no-op.
UPDATE parcels
   SET parcel_uid = 'LEGACY-' || upper(substr(md5('parcel:' || gid::text), 1, 16))
 WHERE parcel_uid IS NULL;

-- 3. Constrain only once the backfill is complete and verified.
ALTER TABLE parcels
  ALTER COLUMN parcel_uid SET NOT NULL,
  ADD CONSTRAINT parcels_uid_unique UNIQUE (parcel_uid),
  ADD CONSTRAINT parcels_uid_shape CHECK (parcel_uid ~ '^[A-Z]{2,8}-[0-9A-HJKMNP-TV-Z]{16}$');

CREATE INDEX parcels_uid_idx ON parcels (parcel_uid);

-- 4. Keep gid as a non-authoritative legacy reference, documented as such.
COMMENT ON COLUMN parcels.gid IS
  'Legacy sequence key. Non-authoritative: retained so external references resolve. '
  'Joins and exports use parcel_uid.';

Deriving the backfill from a hash of the existing key rather than from random bytes means the migration can be re-run — on a copy, on a replica, on a restored backup — and produce identical identifiers every time. A random backfill makes the migration a one-shot event that cannot be reproduced if anything goes wrong halfway.

Migrating a layer that already uses integers A timeline of an additive identifier migration: the new column is added and backfilled deterministically, constraints are applied once verified, consumers migrate one at a time, and the legacy key is demoted rather than dropped. Add beside the key nothing breaks yet step 1 Backfill deterministically derived from the legacy key step 2 Constrain and index unique, not null, shaped step 3 Demote the legacy key kept, documented, non-authoritative step 4 A deterministic backfill means the migration can be re-run on a replica and produce identical identifiers.

Validation & Output Verification

-- Every feature has a well-formed, unique identifier
SELECT count(*) FILTER (WHERE parcel_uid IS NULL)                    AS missing,
       count(*) - count(DISTINCT parcel_uid)                         AS duplicated,
       count(*) FILTER (WHERE parcel_uid !~ '^[A-Z]{2,8}-[0-9A-HJKMNP-TV-Z]{16}$')
                                                                     AS malformed
  FROM parcels;
-- expected: 0, 0, 0
# Minting is collision-free at field rates, and sortable by capture time
from identifiers import mint, minted_at_ms, is_valid

ids = [mint("EAST") for _ in range(200_000)]
assert len(set(ids)) == len(ids), "collision at field capture volume"
assert all(is_valid(i) for i in ids)

# Two devices minting concurrently must not collide either
a = {mint("EAST") for _ in range(50_000)}
b = {mint("WEST") for _ in range(50_000)}
assert not (a & b)

# Ordering by identifier orders by capture time
sample = sorted(ids[:1000])
stamps = [minted_at_ms(i) for i in sample]
assert stamps == sorted(stamps)
print("identifiers unique, well-formed and time-ordered")

Run the SQL check on a schedule, not only at migration. A collision that appears months later means a device is minting with a prefix it was not assigned, and that is worth knowing before the affected package is reconciled.

Failure Modes

  • Two features arrive with one identifiersymptom: a unique-constraint violation during sync, or worse, a silent overwrite. Root cause: sequential or block-allocated integers minted on disconnected devices. Fix: move to device-minted identifiers; quarantine affected packages rather than renumbering.

  • A re-imaged device reuses a blocksymptom: collisions confined to one crew. Root cause: block allocation held only on the device. Fix: record allocations centrally, or abandon blocks for prefixed ULIDs.

  • Crews transcribe identifiers wronglysymptom: field notes that do not match any feature. Root cause: an alphabet containing visually ambiguous characters. Fix: use Crockford base32 as above, which omits I, L, O and U.

  • The migration cannot be re-runsymptom: a half-finished backfill leaves the layer in a state nobody can reproduce. Root cause: randomly generated backfill values. Fix: derive backfill identifiers deterministically from the legacy key.

Back to Offline Field Collection and Sync Reconciliation