# 0015. Gate control through a queue and a reconciler

- **Status:** Accepted
- **Date:** 2026-06-21

## Context

Customers open the site gate by calling it from their own phone. The device — a TELL Gate Control PRO — holds
a list of authorised numbers, and until now that list was maintained by hand. Every new rental, every ending
rental and every phone number change was a manual step someone had to remember, and forgetting it either
locked a paying customer out or left a former one with access.

The device is reachable over an HTTPS API, but it is a physical box on a site: it can be offline, slow, or
changed directly on its keypad.

## Decision

**Never call the device from a request. Queue the operation and drain it from cron, then reconcile
periodically.**

- `DoorOpeningPhoneNumber` (rent + phone + device-side `gateUserId`) is the authorisation record.
- `GateSyncQueue::enqueueAdd/Edit/Remove()` writes a job. Guards: the sync must be enabled, the location must
  be gate-enabled with a hardware id, a `REMOVE` for a phone never registered is dropped, and an identical
  pending job is deduplicated.
- `./yii gate-sync/auto` runs every minute: mutex-guarded, time-boxed to 50 seconds, retrying a failed job up
  to `MAX_ATTEMPTS = 10` before leaving it for inspection.
- `./yii gate-sync/reconcile` runs every 15 minutes: compares the active rentals' phones against the device's
  user list and fixes the difference. **Only users named `r24-*` are touched** — anything added on the keypad
  is left alone.
- The device is behind `GateServiceInterface`, with `TellGateService` and `MockGateService` implementations,
  selected by `APP_GATE_SYNC_FAKE`.

## Alternatives

- **Call the device synchronously on save.** An admin saving a rental would wait on a physical box, and a
  device that is offline would fail the save.
- **A queue without reconciliation.** Covers our own failures, not the device's — a reset, a manual keypad
  change or a job that exhausted its retries would leave the two permanently out of step, invisibly.
- **Reconciliation only, no queue.** Up to 15 minutes before a new customer can get in.
- **A generic job queue component.** One table and two cron entries; a framework was not warranted.

## Consequences

- **Saving a rental never touches the network.**
- **The queue is the fast path, reconciliation is the truth.** Anything the queue misses is corrected within
  15 minutes.
- **`MAX_ATTEMPTS` exhausted means the job stops being retried entirely.** It surfaces as an error
  notification on the admin dashboard, rendering the action, the phone and the truncated error — but nothing
  retries it automatically.
- **The `r24-` name prefix is the ownership marker.** Changing it orphans every existing device user from the
  reconciler's point of view.
- **Everything is a silent no-op when `APP_GATE_SYNC_ENABLED` is false**, including queueing. Turning the flag
  on later does not backfill — run `reconcile`.
- **`MockGateService` makes the whole pipeline testable offline**, including retries, the dashboard
  notification and reconciliation.
- The device abstraction takes `$device` as an array assembled from the `Location` at process time, not stored
  on the job — so a job queued before a device was reconfigured picks up the new coordinates.
- `deleteUser()` treating a missing user as success is what makes retries idempotent.
