# Fleet config backups: adding a kind, and how health is judged

This is about the `backups` plugin - the per-PC CONFIG backups the collector
takes off shopfloor machines (NTLARS settings, part-marker configs). It is not
about backing up the ShopDB database itself; that is `docs/BACKUP-RESTORE.md`.

Two things this covers:

1. [Adding a new backup kind](#adding-a-new-backup-kind)
2. [How the Backup badge decides good vs stale](#how-the-backup-badge-decides-good-vs-stale)

---

## Adding a new backup kind

A kind is one class in `plugins/backups/services/registry.py`, added to
`REGISTRY` at the bottom of that file:

```python
REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}
```

Subclass `BackupKind` and override what applies. `NtlarsKind` is the fullest
example (parseable, renderable, has an info panel); `PartMarkerKind` is the
lean one.

| Member | What it is |
|--------|------------|
| `key` | Wire value. This is what lands in `backuprevisions.backupkind` and what the fleet table shows on the badge. Keep it short and lowercase. |
| `displayname` | Human label for the UI. |
| `storagebackend` | `'shopdb'` (bytes live in the DB, deduped on a semantic hash) or `'share'` (file lives on the SMB share, deduped on a raw byte hash). |
| `assettypes` | Which core asset types this kind can attach to; `['*']` for any. |
| `emptytext` | Text when an asset has no revisions of this kind. `None` HIDES the panel, which is the right default - a kind applies to an asset TYPE, but whether a given machine ever has that backup is per-machine. A part-marker panel on all 144 machines is noise. |
| `parse(raw)` | Opaque kinds return `None`; parseable kinds return a projection dict. |
| `formats()` / `render(...)` | Download formats, for `shopdb` kinds. |
| `resolveassetid(payload)` | Map a collector payload to the asset the backup belongs to. |
| `infopanel()` / `buildinfo(...)` | Optional at-a-glance card (ADR-010). Declared by the KIND, not hardcoded in the plugin, so a successor technology ships its own card by adding a class. |
| `sharedir(...)` | Conventional UNC directory for a `share` kind. Advisory - the authoritative path is whatever the collector reported, because the PC is what actually wrote the file. |

### What you do NOT have to touch

The GE-Enforce **Enforcement Reports** Backup column needs no change. It reads
`backuprevisions` generically: newest revision per host, whatever the kind, and
shows `backupkind` on the badge. A new kind inherits the badge, the colour and
the tooltip with no work.

Same for the staleness rule below - it is time-based and kind-agnostic.

---

## How the Backup badge decides good vs stale

### The trap this is built around

**A revision is only written when the config CHANGES.** Dedup compares against
the latest revision for the chain `(asset, kind, sourcehostname)`, so a machine
whose config has been stable for six months has a six-month-old newest revision
and is perfectly healthy. The question worth answering is not "when was the last
backup taken" - it is "is this still being checked".

So the timestamp the fleet table uses is `backuprevisions.lastseenat`: the last
time the collector CONFIRMED this config, whether or not anything changed. It
moves on every successful collection; the revision does not.

That was shown as a raw date at first, and it read as neglect. At the default
`backups_intervalhours` of **24**, the collector only attempts once a day, so a
day-old confirmation IS the healthy steady state. The date made a working system
look like a stalled one, and made the reader do arithmetic against a setting
they would have to go and look up.

### What it does now

`GET /api/geenforce/reports` returns, per host:

| Field | Meaning |
|-------|---------|
| `backupkind` | Which kind was most recently confirmed. `null` = no backup at all. |
| `backuplastseen` | When it was last CONFIRMED still current (ISO). Tooltip only. |
| `backupcollectedat` | When the config was last CAPTURED (ISO). Only moves on a real change. Tooltip only. |
| `backupok` | `true` good, `false` stale, `null` nothing to judge. |
| `backupstaleafterdays` | The threshold in force, so the UI can explain itself. |

Both dates are reported because they answer different questions and one cannot
stand for both. The hover reads *"ntlars: checked 13 Aug 1:20 PM. Verified the
backup taken 12 Aug 5:20 PM is still current."* - the first date proves the
check is running, the second says what is actually stored. Collapsing them was
what made a healthy machine look neglected.

`backupok` is deliberately **tri-state**. `null` means there is no revision for
that host, or the check is disabled - and it renders as no badge, never green.
"Never seen" must not read as healthy.

The threshold is the backups plugin's own `backups_staledays` setting (default
**3**), read through `plugins.backups.services.staleness.staledays()` rather
than re-derived, so there is ONE definition of stale. `0` disables the check.
The import is guarded, so a lean site build without the backups plugin returns
`null` instead of failing.

### Why time-based rather than per-kind

Every kind answers the same question the same way: something confirmed this
recently, or it did not. Making the rule per-kind would mean each new kind has
to define health before it can show a badge, for no gain. If a kind ever needs
its own window - a weekly backup that should not be judged on a 3-day rule -
add a threshold override on `BackupKind` and have `_backup_stale_cutoff` prefer
it; the tri-state contract stays as it is.

### Where the code lives

- `plugins/geenforce/api/routes.py` - `_attach_backup_state` (newest revision
  per host), `_backup_stale_cutoff` (threshold), `_backup_ok` (verdict)
- `plugins/geenforce/frontend/views/EnforcementReports.vue` - `backupClass`
  (green / red / none), `backupTitle` (the hover text)
- `plugins/backups/services/staleness.py` - the shared threshold, also behind
  the dashboard's stale-backups card

Tests: `tests/test_plugins/test_geenforce_reporting.py`, the backup-verdict
block - recent-is-good, older-than-threshold-is-stale, and none-is-not-green.

---

## See also

- `docs/BACKUP-RESTORE.md` - backing up the ShopDB database itself
- `docs/geenforce-api-cutover.md` - the fleet reporting path these fields ride on
