Device Lifecycle
:::info Status
Implemented. Source: docs/device-management-module-architecture.md §5a–§5d; fastapi-backend/core/device_registry.py (transition matrix matches the doc exactly, verified against source as of this writing).
:::
Transmitters and stream players walk the same lifecycle — no shortcut
for either kind. Every purchased unit walks it once, on its own; a second
unit an owner buys starts back at PENDING however many they already have.
Nothing a member does afterward sends a unit back down this path.
States
¹ unblock restores ACTIVE only if inventory and assignment still
satisfy the in-service constraint; otherwise it lands on VERIFIED.
Blank cells in the transition matrix mean 409 Conflict — never a
silent no-op; an already-ACTIVE unit that quietly "succeeds" on a bad
transition would tell an operator the unit was moved when it wasn't.
Transition guards
| From → To | Guard |
|---|---|
PENDING → VERIFIED | inventory_id NOT NULL; serial + MAC match inventory (Identity); superadmin only. Issues device_secret |
VERIFIED → ACTIVE | an open assignment must already exist; inventory status ∈ (ALLOCATED, DEPLOYED); stream players additionally require an open device_ownership row |
ACTIVE ⇄ INACTIVE | superadmin; reversible |
any non-terminal → BLOCKED | reason required |
any non-terminal → LOST | reason required; credentials invalidated on entry |
BLOCKED/LOST → REVOKED | reason required; terminal |
ACTIVE/INACTIVE → RETIRED | inventory → SCRAPPED; assignment force-closed; terminal |
activate needs an open assignment to already exist — it cannot create
one. A VERIFIED unit with no assignment cannot become ACTIVE; the
owner (or Super Admin) must bind first, then activate. This guard fires
once, at activation — never again — which is why ACTIVE + UNBOUND is a
perfectly normal state, reachable the instant the owner unbinds afterward:
VERIFIED + UNBOUND ──activate──► refused, no open assignment
VERIFIED + BOUND ──activate──► ACTIVE + BOUND ──owner unbinds──► ACTIVE + UNBOUND
POST /devices/{id}/approve performs verify + activate in one
transaction — the common path for an already-sold, already-bound unit.
BLOCKED, LOST, REVOKED all invalidate credentials immediately, in
one transaction: clear device_secret_hash + broker credential → publish
deauthorize → clear retained config → drop broker session → revoke
outstanding jti → invalidate the authorization cache. LOST differs from
REVOKED only in being reversible, not in speed — a stolen unit powered on
is still holding valid credentials until this runs.
Two independent axes — lifecycle vs. binding
lifecycle_status (this page) and binding_state
(Mosque Binding, always derived, never stored)
are separate axes with separate writers:
| # | Invariant |
|---|---|
| L1 | Exactly one combination plays: ACTIVE ∧ BOUND |
| L2 | A binding transition never writes lifecycle_status |
| L3 | A lifecycle transition never writes memberships, and never closes an assignment — except RETIRED/REVOKED/LOST, which force-close it in the same transaction |
| L4 | binding_state is derived, never stored |
| L5 | An open assignment may exist under any lifecycle state, including PENDING |
| L6 | A terminal transition must close the open assignment in the same transaction that changes lifecycle_status |
| L7 | Bluetooth is a function of neither axis |
core/device_registry.py owns every lifecycle write; core/device_binding.py
owns every binding write. A function touching both is a bug by definition —
this split is what makes L2/L3 enforceable rather than aspirational.
The full state × binding matrix
Any combination not in this table is invalid, and the API must refuse to create it:
| lifecycle | binding | Azaan | Means |
|---|---|---|---|
PENDING | any | ❌ | Claimed, awaiting Super Admin |
VERIFIED | UNBOUND/BOUND | ❌ | Verified in stock, or assigned but not yet activated |
ACTIVE | BOUND | ✅ | The only playing combination |
ACTIVE | MEMBERSHIP_LAPSED | ❌ | Authorized unit, owner left the mosque |
ACTIVE | UNBOUND | ❌ | Authorized unit between mosques |
INACTIVE/BLOCKED/LOST | any | ❌ | Maintenance pause / credentials invalidated |
RETIRED/REVOKED | UNBOUND only | ❌ | Terminal; binding force-closed on entry |
Inventory asset lifecycle — a related, separate state machine
device_inventory.status (IN_STOCK, ALLOCATED, DEPLOYED, IN_REPAIR,
LOST, SCRAPPED) tracks the physical asset, moving for different
reasons than the device row above:
ALLOCATED/DEPLOYEDis a precondition for the device to becomeACTIVE— not something the device transition triggers itself; it happens at claim.LOST/SCRAPPEDcascade to the device row — the asset going away takes the device with it (device forced toLOST/RETIRED, credentials cleared, assignment force-closed, all in one database trigger).IN_REPAIR/IN_STOCKdo not cascade — refused while a device is still in service; deactivate or retire the device first.
Full inventory operations: Device Management module.
Broker access follows lifecycle, but the ACL never changes shape
A device's MQTT topic subtree ACL is static for the unit's life — what varies is whether authentication itself succeeds:
| lifecycle | Authenticates? | Effect |
|---|---|---|
PENDING / VERIFIED | ✅ | Connects, reports status, receives config without stream_url |
ACTIVE | ✅ | Full operation |
INACTIVE | ✅ | Connected and visible; no play published to it |
BLOCKED/LOST/REVOKED/RETIRED | ❌ (device_secret_hash cleared) | CONNECT refused; any live session kicked |
A claimed-but-unapproved unit is deliberately allowed onto the broker —
that's what makes approval instant. It simply can't stream: no
stream_url in its config, no token would ever be issued for it. Full
detail: MQTT Protocol.