Mosque Binding
:::info Status
Implemented. Source: docs/device-management-module-architecture.md §1, §5a-ii; fastapi-backend/api/v1/endpoints/my_devices.py.
:::
Binding answers: which mosque's azaan does this unit currently follow?
It is the second axis, deliberately independent of
device authorization — a PENDING unit can be
bound; it just doesn't play.
| Concept | Lives in | Changed by | Never changed by |
|---|---|---|---|
| Binding | device_assignments (one open row per device) | owner rebind, superadmin move | approval, ownership transfer |
Hard invariant, enforced by the database: one mosque at a time, via a
unique partial index (unassigned_at IS NULL) — a rebind race resolves to a
constraint violation and a clean 409 retry, never a unit silently bound to
two mosques. See Concurrency below.
binding_state — derived, never stored
Computed by the same code path as device authorization, per request:
binding_state | Condition | Azaan | Bluetooth |
|---|---|---|---|
BOUND | open assignment and owner's membership approved | ✅ | ✅ |
MEMBERSHIP_LAPSED | open assignment, but membership removed / pending / rejected | ❌ | ✅ |
UNBOUND | no open assignment | ❌ | ✅ |
For a transmitter, binding alone (plus device authorization) is sufficient — a transmitter has no owner, so there's no membership to check. For a stream player, binding requires the current owner's membership at the bound mosque to be approved — see Playback Entitlement for the full predicate.
Lapse does not destroy binding
This is the detail that makes recovery free. On BOUND → MEMBERSHIP_LAPSED,
the backend stops issuing stream tokens and publishing play, and
republishes config without a stream_url — but it does not close the
assignment, write any status column, send deauthorize, or wipe
credentials. The open assignment surviving means restoring the membership
evaluates straight back to BOUND — no rebind, no re-claim, no operator
action.
Rebinding needs no Super Admin
The unit stays authorized throughout; moving it between mosques the owner legitimately joined is a normal life event ("I moved house"), not a queue item — gating a routine rebind behind Super Admin review would turn that into a support ticket. Guard is only that the target is a mosque where the current owner holds an approved membership.
Owner-facing API
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/my-devices | Caller's units with effective_playback_state, bound mosque, last azaan |
GET | /api/v1/my-devices/{id}/rebind-targets | Mosques the caller holds an approved membership of |
POST | /api/v1/my-devices/{id}/rebind | Guarded on ownership, ACTIVE, approved membership of the target. Audited as assign_mosque, reason='owner_rebind' |
POST | /api/v1/my-devices/{id}/unbind | Same guards; clears binding |
Concurrency
Two rebinds of the same device landing at once are serialized with
SELECT … FOR UPDATE on the device row; the unique partial index on
device_assignments is the backstop that turns any escape into a
constraint violation rather than a device bound to two mosques. The API
retries once, then returns 409. Never a check-then-insert pattern — the
gap between the two is exactly where a second concurrent writer would land.
Cross-axis rule
A binding transition never writes lifecycle_status, and a
lifecycle transition never writes memberships or closes an assignment
— except the terminal transitions (RETIRED/REVOKED/LOST), which
force-close the open assignment in the same transaction as the lifecycle
change. See Device Lifecycle for the full
state machine.