Skip to main content

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.

ConceptLives inChanged byNever changed by
Bindingdevice_assignments (one open row per device)owner rebind, superadmin moveapproval, 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_stateConditionAzaanBluetooth
BOUNDopen assignment and owner's membership approved
MEMBERSHIP_LAPSEDopen assignment, but membership removed / pending / rejected
UNBOUNDno 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

MethodPathPurpose
GET/api/v1/my-devicesCaller's units with effective_playback_state, bound mosque, last azaan
GET/api/v1/my-devices/{id}/rebind-targetsMosques the caller holds an approved membership of
POST/api/v1/my-devices/{id}/rebindGuarded on ownership, ACTIVE, approved membership of the target. Audited as assign_mosque, reason='owner_rebind'
POST/api/v1/my-devices/{id}/unbindSame 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.