============================================================
Status: BUILT AND LIVE 2026-10-13 — https://gp.veripath.co.uk/infrastructure/maintenance/ (delivered the
same day it was approved).
Raised by Matthew: a dedicated view in the GP Booking App carrying all maintenance, patching and upgrade
information, rather than it living in hand-maintained wiki tables.
| Piece | Delivered |
|---|---|
| Model | MaintenanceRecord in the infrastructure app — every field in the v0.1 design, idempotent on (host, kind, started_at); migration 0008, isolated to these changes |
| Cadence model | MaintenanceCadence (migration 0009) seeding the four scheduled jobs — an addition to the design, and a good one: the status strip reads the cadence rather than hard-coding it |
| Auth | PlatformVPS.maintenance_token (set for all three hosts), host-local in /etc/veripath/maintenance.env (0600), never in git; ingest uses X-Host-Token with a constant-time compare |
| Ingest | POST /infrastructure/api/maintenance-record/ — verified rejecting a missing/bad token, accepting a real post, and recording failures as well as successes |
| View | /infrastructure/maintenance/ — newest-first table, filters (host / kind / date / outcome), status strip (next run + packages and reboot outstanding), CSV export, a Maintenance nav tab and a dashboard card |
| Manual entry | staff-only form; source = the user, trigger forced to manual |
| Reporters | patch_monthly.sh, patch-host.sh, refresh-image.sh (failure on any early exit via a trap), and the rotation pack's finalize.sh → the 2026-10-18 rotation is a credential_rotation row from day one |
| Tests | 12, via scripts/test_with_disposable_db.sh (ingest auth/idempotency/failure, view RBAC, CSV, manual entry, cadence strip) |
Deployed from gp_booking_app 824e8d4 (branch feature/wda-register-import); ops side in veripath-ops
(bc14fc4, 9ff1f9e, 2c53a67, 92d6d15). First row: vps2 | os_patch | success | applied=0 | pending=44 | reboot=required | source=patch-host.sh, and the four cadence rows are seeded (vps1 02:00 / vps2 03:00 /
vps3 04:30 / image refresh 2027-01-02). Constraint honoured: no DSPT assertion numbers in the UI or export.
Verified by me read-only: the row, the four cadence rows, the token set on all three hosts, the route live
(403 unauthenticated / 404 bogus, so it exists) and the host-local token file at 0600. The rendered layout I
have not seen with my own eyes — authentication is required.
DSPT asks not only that patching happens but that you can show when, where and how it happened. Today
that record is DSPT Standard 9: Patching Log — a wiki page I update by hand
from each host's /var/log/patching/summary.log. It is accurate and auditable, but it is manual, it lives
in a second system, and it is not where anyone looks during an incident.
The estate already has everything needed to make it live: the app ingests host-side operational records
today.
Caution on quoting: do not put DSPT assertion numbers on this page or into the submission until they
have been checked against the current DSPT assertion list. The retired research page quoted items 5.1.1,
8.3.5 and 10.2.1 for this requirement; the mapping is plausible but unverified, and a wrong assertion
reference in an evidence pack costs more than no reference at all.
| Piece | Where |
|---|---|
| Infrastructure dashboard | /infrastructure/ (InfrastructureDashboardView) |
| Host registry | PlatformVPS — name (vps1/2/3), role, hostname, status, last_seen_at |
| Host→app ingest precedent | POST /infrastructure/api/backup-heartbeat/ (backup_heartbeat), written by veripath-ops/scripts/client-backup/backup.sh per tenant |
| Same pattern, twice more | ClientBackupLog, SecurityCheckIn (the latter stores a status_json — a good precedent for a detail field) |
| Ops scripts that would report in | veripath-ops/scripts/patch-host.sh, patch_monthly.sh, refresh-image.sh |
So this is not a new pattern — a maintenance record is a third instance of one the app already uses.
1. Model MaintenanceRecord in the infrastructure app:
| Field | Type | Notes |
|---|---|---|
host |
FK → PlatformVPS |
the three hosts are already registry rows |
kind |
choices | os_patch, image_refresh, credential_rotation, schema_migration, client_tier_migration, other |
started_at / finished_at |
datetime | |
trigger |
choices | scheduled, manual, one_off |
outcome |
choices | success, partial, failed |
applied_count / pending_after |
int, nullable | the numbers from summary.log |
reboot |
choices | none, required, performed |
summary |
text | one line, same shape as /var/log/patching/summary.log |
evidence_path |
text | log path or artefact reference — points at the evidence, never inlines it |
source |
text | reporting script, or the staff user for a manual entry |
notes |
text | free text |
Idempotency: unique on (host, kind, started_at) so a re-POSTed run cannot double-log.
2. Ingest endpoint — POST /infrastructure/api/maintenance-record/, token-authenticated on the existing
pattern (per-host token, as the backup heartbeat uses a per-tenant one). Failed runs also post: a
maintenance log that only records successes is not evidence.
3. Host-side reporting — patch_monthly.sh (primary), patch-host.sh (app engine, and the auxiliary
host if its run is added), refresh-image.sh, and the rotation's finalize step each POST one record per
run, whether it succeeded or not. No new data collection: it is the same line already written to
summary.log.
4. The view — /infrastructure/maintenance/:
/infrastructure/ dashboard linking to it.Same visual language as the current infrastructure dashboard; staff-authenticated.
5. Manual entry — a small form (staff-only) for work with no script behind it, e.g. a one-off change or a
corrective action. Without it the log has holes exactly where an auditor probes.
6. Relationship to the wiki (important, avoids two competing records). The app becomes the live
record. tasks/dspt-patching-log stays as: the policy-facing summary, the pre-2026-10-13 historical rows
(which predate any automation), and the cadence declaration — each pointing at the app view for the current
detail. The DSPT evidence map on the assessment page is updated in the same pass.
evidence_path references a log; it nevertasks/dspt-patching-log becomes thecredential_rotation row from day one.Acceptance for v1: a scheduled run on each patched host produces a row with no human action; the CSV
export opens in a spreadsheet with the fields an auditor asks for (date, host, kind, applied, outstanding,
reboot, outcome, evidence path); a failed run appears as failed.
His question: the view shows one row per patching job and the CSV repeats that row. Should we break down
what exactly was updated?
Answer: yes — but behind the row, not in it. One row per run is the right ledger shape; an auditor wants
to sample runs, not read a package dump. What is missing is the detail attached to each row.
Status: BUILT and live 2026-10-13. MaintenanceRecord.detail_json (migration 0010), ingest accepting
it object-only and 512 KB-capped so a whole log upload is refused rather than stored; the view renders an
expandable row (package table for os_patch, key/values otherwise) with a security badge; a second
export at /infrastructure/maintenance/export/packages/ carries date, host, source, package, from, to, security; reporters capture the apt Upgrade: list (from/to from /var/log/apt/history.log,
security from the pre-run upgradable suite), refresh-image.sh now records success as well as failure, and
the rotation posts {roles, carriers} with no values. 17 tests green; openssl row verified end to end and
the synthetic test rows deleted. First real row with packages: the app engine's 2026-10-14 03:00 bootstrap.
Where the data is today: on each host, not in the record. The run log (/var/log/patching/patch_<date>.log
— what evidence_path points at) carries the apt output, and apt's own /var/log/apt/history.log records
Upgrade: lines with old → new versions per run. On the primary, the 2026-10-01 run alone has 535
package lines in its log. The app cannot read a remote host path, so an auditor sampling a run currently gets
a file path and has to shell into the host.
Proposed shape (small build):
Upgrade: list for the run window and posts it as structured detail —packages: [{name, from, to, security}].detail_json on MaintenanceRecord, with a per-kind convention:os_patch → packages; image_refresh → service + old/new digest; credential_rotation → the roles andschema_migration → app label + migration names.Why the cost is worth it: an auditor can answer "what did the 1 October run apply?" without host access,
and when a regression follows a patch run the first question is what changed. This is not a v1 defect — it is
a gap visible only when you look at the artefact from the auditor's side.
Deliberately not proposed: uploading whole log files into the app (large, repetitive, host paths); one row
per package in the maintenance table (it would swamp the ledger); any secret in a rotation detail row.
F5 — a report-only image sweep is an audit, not a refresh. refresh-image.sh used to post
kind=image_refresh, outcome=success for a check that found a newer digest but did not apply it, so the ledger
said "success" for a refresh that never happened (19 such rows on vps1 at the last count, accumulating every
quarter). The convention now:
--apply → kind=image_refresh (an actual refresh; success/partial/failed as before).kind=image_audit, outcome=success (the audit ran); a pull/check failure → outcome=failed.The stale service, the old → new digest and "not applied" stay in summary/detail_json. The outcome vocabulary
is unchanged (success|partial|failed), so existing filters, the status strip and the exports read normally; audits
are separated by kind, not by overloading "success". MaintenanceRecord.Kind gained image_audit — a choices
change only (the column is a CharField), so no migration.
F3 — pooler hardening (config). generate_pooler_config now writes a separate
admin_users/stats_users credential — generated once and persisted 0600 as admin.env in the pooler config
dir, or supplied as POOLER_ADMIN_USER/POOLER_ADMIN_PASSWORD — so the web process's login is a plain client and
cannot RELOAD/PAUSE the pooler. It also sets log_connections = 1 and log_disconnections = 1: with one
pooler identity for the app, that log is the per-connection record ("who opened a connection to which database,
when") an auditor asks for. Applied to a live pooler by regenerating and reloading — a deploy step, not a code
change alone.