---
module: recon.device-cycle-mgmt
title: Device Cycle Management
tab: Claude
order: 40
audience: dev
---

> Terse agent reference. Only confirmed facts. Owner service: **`hiveops-recon`**.

## Owns

- Service: `hiveops-recon` (port 8091, NGINX `/api/recon/`, prod ext 8013), DB `hiveiq_recon`.
- Feature = "recon device selection": which ATMs appear in Cash Reconciliation.
- Frontend: `frontend/src/components/DeviceCycleMgmt.svelte`.

## Endpoints (exact)

| Method | Path | Auth | Body / returns |
|---|---|---|---|
| GET | `/api/recon/device-selection` | none (any authed) | → `{ "selected": string[], "all": string[] }` |
| PUT | `/api/recon/device-selection` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | body = `string[]` (ATM IDs); → 204 |
| POST | `/api/recon/device-selection/enroll/{atmId}` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | → 204; idempotent single-add |

- PUT body is a **bare JSON array**, not an object.
- PUT is a **full replace** for the caller's institution scope.

## Table

- `recon_device_selection` (DB `hiveiq_recon`): `id`, `atm_id` VARCHAR(50), `institution` VARCHAR(50) nullable, `created_at`; `UNIQUE(atm_id, institution)`. Flyway `V7`.
- `device_summary` (same DB): source of the `all` list; **read-only** for this feature.

## Kafka

- Consumed: topic `hiveops.device.events`, group `hiveops-recon-device-sync` (`DeviceEventConsumer`) → upserts `device_summary`.
- **No producer.** This feature emits nothing.

## Scope (from JWT, `InstitutionContext`)

- BCOS_ADMIN / ADMIN / QDS_ADMIN → scope `null` → reads/writes `institution IS NULL` rows; `all` = every `device_summary` row.
- CUSTOMER → single institution key.
- MSP_ADMIN → list of granted institution keys.
- GET `selected`: `null`→`findByInstitutionIsNull()`; scoped→`findByInstitutionIn(scope)` (union).
- GET `all`: `null`→`device_summary.findAll()`; scoped→decode JWT `institutionKey` claim, `device_summary.findByInstitutionKeyIn`.

## Gotchas

- **Empty selection = show ALL ATMs** downstream (`SummaryController` applies no filter when list empty).
- `all` comes from local `device_summary` mirror, **not** a live incident/devices call. Stale mirror → wrong Available list; no reconciler.
- Device events **without `deviceId`** are skipped on insert → that ATM never appears in Available.
- Admin (`NULL`) selection and scoped-institution selections are **separate row sets**; they don't see each other.
- Write uses **only `scope.get(0)`** (first institution) and deletes only that institution's rows; multi-institution MSP_ADMIN writes are lopsided (see Do NOT).
- Three downstream consumers use three scopings: summary=`getSelected(scope)`, forecast=`getAllEnrolledAtmIds()` (all institutions), daily ingest cron=`getSelected(null)` (global only).
- GET is unauthenticated at method level — don't assume reading it proves admin rights.

## Do NOT

- Do NOT send PUT body as `{ "atmIds": [...] }` — it must be a raw array `[...]`.
- Do NOT expect a CUSTOMER/BANK token to save (PUT/POST are admin-only → 403).
- Do NOT treat an empty Available list as a selection bug — check `device_summary`/Kafka first.
- Do NOT assume saving as admin changes what a customer sees — different (`NULL` vs institution) rows.
- Do NOT rely on multi-institution MSP_ADMIN saves being per-institution — write collapses to the first key.
- Do NOT look for this table/endpoint in incident/devices/journal — it lives only in `hiveops-recon`.
- Do NOT expect a Kafka publish on save — there is none.
