---
module: fleet.patch-windows
title: Patch Window Guide
tab: Architect
order: 30
audience: dev
---

How HiveIQ Patch Windows are built. Everything lives inside **hiveops-fleet** (`com.hiveops.fleet`, Spring Boot 3.4, port 8097, DB `hiveiq_fleet`). Patch windows are a scheduling overlay on the existing fleet-task pipeline — no dedicated microservice. Cross-link [platform.data-architecture], [platform.kafka].

## Components (this feature)

| Layer | Class | Role |
|-------|-------|------|
| Controller | `PatchWindowController` (`/api/fleet/patch-windows`) | CRUD for windows + device assign/unassign |
| Service | `PatchWindowService` | Persistence, institution-scope checks, and `nextOccurrence()` slot math |
| Entities | `PatchWindow`, `PatchWindowDevice` | Map `patch_windows` and `patch_window_devices` |
| Repos | `PatchWindowRepository`, `PatchWindowDeviceRepository` | JPA access |
| Scheduling hook | `FleetTaskService.createTask(...)` | On `*_INSTALL` task creation, calls `nextOccurrence()` to set `scheduled_at` |
| Dispatch gate | `FleetTaskService.getNextPendingTask(...)` | Filters out tasks whose `scheduled_at` is in the future |
| Enrichment | `DeviceSummaryRepository` | Joins `device_summary` into the `/atms` response |
| Frontend | `FleetManagement/PatchWindows.svelte` + `lib/api.ts` (`fleetAPI`) | Table, create/edit slide panel, assigned-device panel |

## Data model (DB: `hiveiq_fleet`, Flyway-managed)

- **`patch_windows`** (V1 `V1__init_fleet.sql`): `id, institution_key, name, days_of_week (comma string), week_of_month (nullable), start_time, end_time, timezone (default 'UTC'), created_at, updated_at`. Unique index `(institution_key, name)`.
- **`patch_window_devices`** (V4 `V4__patch_window_devices.sql`): `id, patch_window_id → patch_windows(id) ON DELETE CASCADE, atm_id, institution_key, created_at`. `UNIQUE(patch_window_id, atm_id)`. Deleting a window cascades its device rows.
- V4 also **dropped `device_summary.patch_window_id`** (the old, never-populated column) — assignment authority moved fully into the join table. Fleet owns patch-window↔device assignment; devices/mgmt do not.

Cross-link [platform.data-architecture].

## Data flow

### 1. Define a window (admin, synchronous HTTP)
`PatchWindows.svelte` → `fleetAPI.createPatchWindow` → `POST /api/fleet/patch-windows` → `PatchWindowService.create` persists a `patch_windows` row. Update/delete/assign/unassign are the same synchronous path. No Kafka is emitted for window CRUD.

### 2. Attach devices
`PUT /api/fleet/patch-windows/{windowId}/atms/{atmId}` → `assignDevice` inserts a `patch_window_devices` row (idempotent: skips if the pair already exists). `institution_key` is copied from the local `device_summary` row if present, else from the window.

### 3. Schedule an install (the interesting path)
When a `HOTFIX_INSTALL` / `SOFTWARE_INSTALL` fleet task is created **without** an explicit `scheduledAt`:

```
FleetTaskService.createTask
  └─ patchWindowDeviceRepository.findByAtmId(atmId).findFirst()   // is this ATM in a window?
       └─ patchWindowRepository.findById(windowId)
            └─ PatchWindowService.nextOccurrence(window)          // next start slot → UTC LocalDateTime
                 → task.scheduledAt = <UTC start of next window>
```

`nextOccurrence` walks day offsets 0..35 in the window's `timezone`, matching `days_of_week` and (if set) `week_of_month`, returns the next `start_time` slot **converted to UTC**, then throws `IllegalStateException` if nothing matches within 35 days. `createTask` catches any failure, logs a WARN, and leaves `scheduled_at = null` (task runs after approval). All `HOTFIX*/SOFTWARE*` tasks are created in status `PENDING_APPROVAL`.

### 4. Dispatch (agent poll)
Agent → agent-proxy → `GET /api/internal/fleet/tasks/next` (`ROLE_INTERNAL` via `X-Internal-Secret`). `getNextPendingTask` returns the oldest `PENDING` task **filtered by** `scheduledAt == null || scheduledAt <= now`. So a window-scheduled task is invisible to the agent until the window's start time passes (after it has been approved → moved from `PENDING_APPROVAL` to `PENDING`). Only the start is gated; there is no end-time cutoff.

### 5. Kafka side-effects (task lifecycle, not window CRUD)
The patch window itself produces/consumes nothing. It rides the standard fleet-task pipeline:

| Direction | Topic | Class | Notes |
|-----------|-------|-------|-------|
| Consumed | `hiveops.device.events` | `DeviceEventConsumer` (`groupId=hiveops-fleet-device-sync`) | Keeps `device_summary` fresh; used for assign-time `institution_key` and `/atms` enrichment. |
| Produced | `hiveops.fleet.task.events` | `FleetTaskEventPublisher` (const `FleetTaskKafkaConfig.FLEET_TASK_EVENTS_TOPIC`) | `created`/status/`archived` events for the scheduled task, consumed downstream (devices stores the durable record). |

This is the executor→Kafka→record-store pattern: fleet executes/tracks the task and emits events; the durable device-facing record lives in **hiveops-devices**, not fleet. Cross-link [platform.kafka].

## API surface (this feature)

| Method + Path | Purpose | Roles |
|---------------|---------|-------|
| GET `/api/fleet/patch-windows` (`?institutionKey=`) | List (scoped) | read roles |
| GET `/api/fleet/patch-windows/{id}` | Fetch one | read roles |
| POST `/api/fleet/patch-windows` | Create | ADMIN/MSP_ADMIN/BCOS_ADMIN |
| PUT `/api/fleet/patch-windows/{id}` | Update | ADMIN/MSP_ADMIN/BCOS_ADMIN |
| DELETE `/api/fleet/patch-windows/{id}` | Delete (cascades devices) | ADMIN/MSP_ADMIN/BCOS_ADMIN |
| GET `/api/fleet/patch-windows/{id}/atms` | Assigned devices + `device_summary` enrichment | read roles |
| PUT `/api/fleet/patch-windows/{windowId}/atms/{atmId}` | Assign device | ADMIN/MSP_ADMIN/BCOS_ADMIN |
| DELETE `/api/fleet/patch-windows/{windowId}/atms/{atmId}` | Unassign device | ADMIN/MSP_ADMIN/BCOS_ADMIN |

Read roles = `USER, CUSTOMER, ADMIN, MSP_ADMIN, BCOS_ADMIN`. Scope enforced via `InstitutionContext.resolveScope()` on all paths except `create`.

## Build notes / edge cases

- **`nextOccurrence` day parsing** expects `DayOfWeek` enum names (`MONDAY`…); the current UI stores short codes (`MON`…), so UI-created windows currently fail the schedule computation and fall through to null-`scheduledAt` (immediate-after-approval). Treat window scheduling as best-effort until `days_of_week` normalization is reconciled.
- **`week_of_month`** is computed as `ceil((dayOfMonth + firstDayOfMonthWeekday - 1) / 7)`.
- **DTO/frontend drift:** DTO exposes `deviceCount`; the frontend `PatchWindow` interface reads `atmCount`. Backend count is authoritative (`countByPatchWindowId`).
- **One-window-per-ATM is not enforced** at the DB level (`UNIQUE` is per window+atm only); the scheduler resolves ambiguity by `findFirst()`.
