ADR-0116: The System Maintenance Surface — the App Backs Itself Up, the Host Keeps One Dumb Job¶
- Status: accepted (2026-08-15 — his marks on
docs/planning/system-maintenance-options.md: "read and I mostly agree with your decisions", the M-2 discussion resolving to the hybrid on his convenience argument, then "let's start with the maintenance screen") - Date: 2026-08-15
- Deciders: Nicolas Burri
- Refines: ADR-0060 — its §1 scripted-cron mechanism is demoted to the drilled fallback and the records-control evidence moves in-app; ADR-0060's ordering rule, restore-verification discipline, retention/re-disposal interplay and deploy-level-restore rule all stand unchanged and are what this ADR builds on. (Link added 2026-09-04 — the relationship was undeclared in both directions until the ADR-coherence review found it.)
- Relates to: ADR-0018 (nonfunctional targets — the "stored separately" backup target), ADR-0106
(the deletability-reflection grammar this surface's findings reuse), ADR-0111 route 4 (the portable
project export this is explicitly NOT), register #109 (the ops-evidence instinct), the
EXPORT_BACKUPbootstrap seed (the ghost permission this ADR gives a home).
Context¶
Backups have been entirely deploy-level: deploy/backup.sh (pg_dump + blob staging + a verification
line + retention pruning), invoked over SSH or cron, invisible to the app — the ISO 13485
records-control answer lived in a shell log. EXPORT_BACKUP was seeded into the System Administrator
role at bootstrap and referenced nowhere. reindexAll existed REST-only; the GC/task/notification
sweeps ran with no operator surface. His question ("system maintenance including backups from within
the app?") opened the design; his push-back on convenience moved it from record-and-show to the app
executing its own backups.
Decision¶
- The app executes its own backups, as a first-class audited act. A frontend trigger starts an
ASYNC backup job:
pg_dumpthrough a postgres client SHIPPED IN THE APP IMAGE and PINNED to the compose database version, plus the blob store streamed from the volume the app already owns, archived with a verification pass into a mountedbackups/volume. Two audit events per act:BACKUP_TRIGGERED(the operator) andBACKUP_RECORDED(timestamp, size, blob count, verify verdict) — the evidence is native, no reporting side-channel. The analysis that unblocked this: the script's own consistency is pg_dump + a filesystem walk at slightly different instants, so in-app execution is NO WORSE; and the only credential the app must never hold is the off-box one, which it doesn't need. - The host keeps exactly one dumb job: syncing the
backups/volume off-box (ADR-0018's "stored separately"), holding the one credential outside the app.deploy/backup.shremains the drilled belt-and-braces fallback, unchanged. - Restore is deploy-level forever. You cannot restore the thing you are running on. No in-app restore, no exceptions — the load-bearing split of the whole design.
EXPORT_BACKUPfinds its home: it gates this surface (trigger, download of an archive, the evidence view). The portable PROJECT bundle (ADR-0111 route 4's outbound twin) is a different question ("move this project" vs "survive the server dying") and will mint its own permission with its own ADR.- The admin System page carries: the backup evidence (last backup, its verdict, history), the
trigger button with running status, the existing
reindexAllbehind a button (its MANAGE_SYSTEM_SETTINGS gate and audit unchanged), and read-only sweep heartbeats (GC, task, notification delivery). Zero new execution paths beyond the backup job itself. - Installation-level findings live here (M-4): the page derives
NO_VERIFIED_BACKUP_IN_7_DAYS(the 7 from the ADR-0018 target, stated in the finding text — not a config knob until a variation point proves itself) in the same finding shape the scope setup-status speaks, GLOBAL-anchored. Future ops findings (certificate expiry, disk headroom) join here if ever wanted; no global banner spam. - Scheduled backups may later join the in-app sweep family — a follow-on, not this slice; cron stays the scheduler until then.
Consequences¶
- App image gains the pinned postgres client (version-coupled to the compose DB — the pin is a stated maintenance duty: DB major upgrades move both).
- One new mounted volume (
backups/), one new async job class with running-status reporting, two new audit event types, one new admin page, the finding. - The backup archive format stays byte-compatible with
backup.sh's (one restore drill covers both producers). - Long-running act discipline: the job streams to the volume (never container tmp), runs bounded,
reports failure as a recorded fact (
BACKUP_RECORDEDwith a failed verdict is still evidence — silence is the only forbidden outcome).