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

> **Architecture.** How the device-selection feature is built and wired. Grounded in `hiveops-recon` source (2026-07-01). Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership].

## Ownership

- **`hiveops-recon` owns this feature end to end** — the selection list, the table, the endpoints, and the local device mirror it reads. No write crosses a service boundary. See [platform.service-ownership].
- Backend port **8091** (NGINX `/api/recon/`, prod external `8013`); DB **`hiveiq_recon`**; frontend `recon.bcos.cloud`.

## Components involved

| Layer | Class / file | Role |
|---|---|---|
| UI | `frontend/src/components/DeviceCycleMgmt.svelte` | Dual-list transfer UI; calls `reconAPI.getDeviceSelection()` / `setDeviceSelection()` |
| API client | `frontend/src/lib/api.ts` → `reconAPI` | `GET`/`PUT /api/recon/device-selection` |
| Controller | `controller/DeviceSelectionController` | 3 endpoints (GET/PUT/POST enroll) |
| Service | `service/DeviceSelectionService` | read/replace/enroll selection rows |
| Entity/repo | `entity/ReconDeviceSelection`, `repository/ReconDeviceSelectionRepository` | maps `recon_device_selection` |
| "All ATMs" source | `service/IncidentFetchService` | reads local `device_summary` (not a live HTTP call) |
| Scope | `security/InstitutionContext` | resolves institution scope from JWT |
| Mirror feed | `kafka/DeviceEventConsumer` + `config/DeviceEventKafkaConfig` | populates `device_summary` from Kafka |

## Data flow — loading the screen (read)

```
Browser → GET /api/recon/device-selection
  DeviceSelectionController.getSelection()
   ├─ InstitutionContext.resolveScope()            // JWT → null (admin) | [institutionKeys]
   ├─ selected = DeviceSelectionService.getSelected(scope)
   │     scope==null → repo.findByInstitutionIsNull()
   │     scope!=null → repo.findByInstitutionIn(scope)   // UNION across granted institutions
   └─ all = IncidentFetchService...
         scope==null → fetchAllAtmIds()                 // device_summary.findAll()
         scope!=null → fetchAtmIdsForUser(token)        // decode JWT institutionKey → device_summary.findByInstitutionKeyIn
  → { selected, all }
Browser computes available = all − selected
```

Note the **"all" source is `device_summary`**, recon's local read-model — not a synchronous call to incident/devices. This keeps the screen fast and lets recon scope by institution offline.

## Data flow — saving (write)

```
Browser → PUT /api/recon/device-selection   body: ["AT000123", ...]
  @PreAuthorize hasAnyRole('MSP_ADMIN','BCOS_ADMIN')
  DeviceSelectionController.setSelection()
   ├─ institution = scope==null ? null : scope.get(0)   // first key only
   └─ DeviceSelectionService.setSelected(institution, atmIds)   [@Transactional]
         delete rows for that institution (or NULL)
         saveAll(new ReconDeviceSelection(atmId, institution))  // full replace
Browser → then reloads fleet summary
```

`POST /device-selection/enroll/{atmId}` is an idempotent single-add (existence check before insert) for concurrent auto-enroll callers (e.g. simulator agents on startup) that avoids the GET→PUT race of the full-replace path.

## Kafka — how `device_summary` is fed

The Available list is only as good as the `device_summary` mirror. See [platform.kafka].

| Direction | Topic | Consumer | Group |
|---|---|---|---|
| Consumed | `hiveops.device.events` | `DeviceEventConsumer` | `hiveops-recon-device-sync` |

- Constants in `config/DeviceEventKafkaConfig`: `TOPIC="hiveops.device.events"`, `CONSUMER_GROUP="hiveops-recon-device-sync"`, offset reset `earliest`.
- Consumer upserts a `device_summary` row per event; `DEVICE_DELETED` removes the row; events **without `deviceId`** are skipped on first insert. Missing `institutionKey` defaults to `"BCOS"`.
- **Consumer-only, no reconciler** — if the upstream device service stops emitting, the mirror (and therefore the Available list) goes stale silently. This is the recon-wide stale-mirror caveat, applied to this screen.

## DB tables (in `hiveiq_recon`)

| Table | R/W here | Shape |
|---|---|---|
| `recon_device_selection` | **read + full-replace write** | `id`, `atm_id` (≤50), `institution` (≤50, nullable), `created_at`; `UNIQUE(atm_id, institution)`, index on `institution` (Flyway `V7`) |
| `device_summary` | **read only** (written by `DeviceEventConsumer`) | local device mirror; keyed by `atm_id`, carries `institution_key`, `device_type`, etc. (Flyway `V10`/`V12`) |

## How the selection is consumed downstream

The selection is a filter, not a hard enrollment, for most reads:

- `SummaryController` fleet summary: applies `getSelected(scope)`; **empty selection ⇒ no filter (all ATMs)**, non-empty ⇒ `retainAll(selection)`.
- `CashForecastService`: uses `getAllEnrolledAtmIds()` — **all rows regardless of institution**.
- `ReconSyncScheduler.ingestJournalReplenishments()` (daily safety-net cron): uses `getSelected(null)` — **only the global `NULL`-institution selection**; empty ⇒ no-op.

These three consumers read the selection with three different scoping rules — relevant when reasoning about why a change on this screen does or doesn't affect a given job.

## Executor → Kafka → record-store pattern

Not applicable here in the classic form: this feature has **no producer** and no async executor. It is a synchronous CRUD screen over `recon_device_selection`, plus a read of the Kafka-fed `device_summary` mirror. The only event-driven part is the inbound `hiveops.device.events` consumer that keeps the Available list current. See [platform.data-architecture].
