Skip to main content

Device Registry Unification

:::tip Status Done. Source: docs/device-registry-unification-implementation-plan.md, header states "Status: Done" against every numbered AZD-* ticket below except one no-op, confirmed by docs/device-registry-unification-jira-breakdown.md ("Progress: all done."). :::

Why this existed

Two full transmitter-provisioning systems ran side by side: the legacy mosque_devices.py path (its own provision_state state machine) and the unified device_registry.py + device_claim.py path that stream players already used exclusively. A migration had folded mosque_devices into devices once, id-preserving — but nothing kept them in sync afterward, so every transmitter claimed through the legacy endpoint after that migration existed only in mosque_devices: invisible to the device registry UI, to invoicing, and to the unified audit trail. This was live drift, not a one-time cleanup — it grew by one row per legacy claim.

Full architecture this unification targets: Device Management module, Device Lifecycle.

What shipped

TicketItemStatus
AZD-132Drop dead transmitters/receivers/v_mosque_devices tables (Supabase-era leftovers, read/written by nothing)Done
AZD-131Drift measurement — how many transmitters exist only in mosque_devicesDone — measured 0 rows of drift on the dev database at time of writing
AZD-133Backfill the drift windowNo-op, confirmed by the AZD-131 measurement
AZD-142Registration creates a device_inventory manifest row (closes a gap where Flows A/B/D never touched a serial)Registration half done
AZD-143Decision: Flow A/B/C claims land PENDING, same authorization gate as every other device kindDecided and adopted
AZD-135Claim write-through — legacy claim functions delegate to core/device_claim.py:claim() instead of writing provision_state directlyDone — verified end to end (register → claim → approve → revoke)
AZD-144Flow D credential hand-off redesign — /announce now runs the real manifest check instead of an unconditional insertDone
AZD-136Approve/reject/force-claim delegationForce-claim done; approve/reject legacy endpoints now moot for new traffic
AZD-134mosque_devices.provision_state becomes a mirror of devices.lifecycle_status, not an independent write targetDone
AZD-137Inventory import unified onto one endpoint (POST /device-registry/inventory/import)Done
AZD-146Telemetry mirror — mosque_devices' connectivity/status columns mirrored onto devices by DB trigger, since a transmitter's live status write target and its devices row have no id relationshipDone
AZD-140FleetPage.jsx transmitter tab repointed to the unified registry endpointDone
AZD-138Legacy GET /mosque-devices/fleet endpoint deletedDone
AZD-139Full HTTP-level E2E test, legacy wire endpoint → unified registryDone

Three findings that changed the plan mid-implementation

The source document is explicit that these were found only by reading the actual code, not by re-deriving behavior from the architecture doc — worth preserving as a caution for similar future unification work:

  1. core/device_claim.py:claim() had a dormant bug — it unconditionally inserted a device_ownership row requiring owner_user_id, contradicting the rule that a transmitter never has an owning member. Fixed: the parameter became optional, with a hard check against the wrong kind supplying (or omitting) it.
  2. Flows A/B/D never touched a serial number at all — only Flow C did, and only against the legacy inventory path. Calling the unified claim function (which requires a pre-existing device_inventory row matched by serial) couldn't work until every transmitter had one — hence AZD-142 being sequenced before the claim delegation itself.
  3. No separate Super-Admin approval step existed for Flows A/B/C — the legacy claim functions set provision_state='claimed' and handed back live broker credentials in the same response. Delegating onto the unified model changes observable behavior: a transmitter now sits PENDING until approved, same as every other device kind, rather than working immediately on claim (AZD-143's decision).

What does not change

  • The MQTT topic scheme and core/device_status.py itself — transmitter and stream-player MQTT bindings stay two bindings in one file, not merged.
  • The firmware-facing /claim and /announce request/response JSON — the documented contract in MQTT Protocol stays byte-identical; this unification changes what happens behind those endpoints, not the wire format.

Sequencing relative to Device Sales Invoices

AZD-116 (the sales-invoices auto-draft hook, see Device Sales Invoices) assumes every claim — transmitter or stream player — goes through core/device_claim.py:claim(). This unification is what makes that assumption true for transmitters; the two were sequenced together deliberately, since shipping the invoice hook first would have silently drafted nothing for transmitters until this unification landed.