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_status —
PENDING → 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 explicitSELECT ... 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/fleetandPOST /mosque-devices/inventorywere 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.