Skip to main content

ADR-0011: Unify Device Provisioning onto a Single Registry

Status

Accepted

Context

Two full transmitter-provisioning systems existed live, side by side: a legacy path (mosque_devices.py, its own provision_state state machine — inventory → registered → claimed → rejected/retired) used exclusively by every transmitter, and a unified path (device_registry.py + device_claim.py, devices.lifecycle_statusPENDING → VERIFIED → ACTIVE → …) used exclusively by stream players. A one-time migration (20260824_0007_migrate_mosque_devices.py) had folded mosque_devices into devices once, id-preserving, at the moment it ran — but nothing kept the two in sync afterward. Every transmitter claimed through the legacy endpoint after that migration existed only in mosque_devices: invisible to GET /device-registry/devices, invisible to device invoicing (whose auto-draft hook lived only in the unified claim path), and invisible to device_audit_log and ownership/assignment history. This was measured as live, growing drift — one row of divergence per transmitter claimed the old way — not a cosmetic duplication.

Decision

Delegate the legacy transmitter-provisioning endpoints (mosque_devices.py's claim/announce/approve/reject/force-claim/inventory functions) onto the unified registry (device_registry.py + device_claim.py), so both device types go through one lifecycle and one set of tables going forward. The already-dead transmitters and receivers tables were dropped outright with no grace period. The firmware-facing /claim and /announce request/response JSON contract was kept byte-identical — only what happens behind those routes changed, not the wire format external firmware builds against. The MQTT topic scheme and core/device_status.py's presence/heartbeat/LWT logic were explicitly left untouched (already correctly "generalized, not forked" for both binding shapes).

Alternatives Considered

A dual-write bridge / shadow-mode harness (the pattern already used elsewhere in this codebase for switches where production traffic is at risk) was considered and explicitly rejected for this cutover — measured production transmitter traffic through the legacy path was zero (stage-only at the time), so the risk a bridge exists to mitigate wasn't present. The cutover was done as a clean, one-shot delegation instead: legacy state-machine functions deleted in the same change that wired the replacement.

Consequences

  • One lifecycle, one registry, one audit trail for both transmitters and stream players going forward — invoicing, fleet listing, and audit history work uniformly across device kinds instead of only for one.
  • Any transmitter claimed through the legacy path in the drift window (after migration 0007, before this cutover) required a one-off reconciliation backfill, verified by an explicit SELECT ... WHERE id NOT IN (SELECT id FROM devices) query rather than assumed to be zero — it was measured, not assumed, and came back as zero rows on the database checked.
  • GET /mosque-devices/fleet and POST /mosque-devices/inventory were deleted outright (not deprecated-and-redirected) — the registry's own pages are the only UI for this once the cutover landed.
  • The decision to skip a dual-write bridge is conditional on the "no production traffic at risk" fact that was true at cutover time — it is not a general precedent that this codebase skips staged rollouts; compare ADR-0007 and ADR-0008, both of which use a staged flag specifically because production traffic was at risk.