FIRST CLIENT BOX INSTALLED AND REPORTING (2026-10-15, session 84). The GP test-estate box
(test-client) now runs the agent: credentials minted in the app, the installer run in the box's own
04:07Z quiet-window slot, four timers live, the firstinventorylanded (record 34 — Ubuntu 26.04,
44 upgradable, 0 security, reboot pending), the signed-job poll answering 204, and the run visible on the
client's own page and in both exports (re-checked live as the tenant's ownmanager, 19/19 checks). The
visit is recorded in the app as its own manual record plus atemporary_credentialrow, and the temporary
root credential was replaced by the estate key and rotated (both directions proven).Two installer defects came out of doing it by hand, both measured on the boxes: (a) the per-box slot
drop-in does not replace the unit's ownOnCalendar— systemd appends, soclient-patch.timercarried
two schedules and its next elapse was the base 02:15, not the box's slot; fixed in place on both boxes
with a bareOnCalendar=reset line (test-client → 04:07Z, canary → 02:23) and handed to OpenCode as a
one-line installer fix; (b) the generated env file still expands a$when sourced (this box's token had
none, so the install is safe) — still open, note (22) on from-hermes.
Also recorded there: the three timed units share one slot minute, so the monthly patch run and the
deferred-reboot check start together on the 1st — a wrinkle for the next installer revision.And the cadence question is now concrete: this freshly installed box's own page reads "Next scheduled
run: not scheduled" while its timer is real (2026-11-01 04:07Z).2026-10-16, follow-up 1 — the boxes now carry the code's own drop-ins. Both installed boxes were
re-slotted by re-running the installer fromveripath-ops483c399(dry run first; each box's own
/etc/veripath/client-patch.envsupplied the credentials, so nothing was re-minted or printed, and the values
are byte-identical before and after): canarytest-client-dental→ patch 02:23 / reboot 02:53 / inventory
03:23 andtest-client→ patch 04:07 / reboot 04:37 / inventory 05:07 — the patch slot re-derived from
the box name, the other two at +30/+60, so no two timers share a minute (the wrinkle above is closed;
(a) and (b) are fixed in the code, and now applied). Measured after: oneOnCalendarper timer with the reset
line, four timers each with one next run, env 0600 and round-trip clean, the re-slot's own inventory landing as
records 44 (canary, 13:07:04Z) and 45 (test-client, 13:08:46Z), every other unit byte-identical to
the pre-run snapshot. One new defect, same class:client-health.servicehas no[Install]section and
nothing pulls it in, so the post-boot health check cannot run at boot — the journal on both boxes holds only
the installer's own start, never a boot. Raised as note (H) on from-hermes.
Pharmacy is the app-side next build (Matthew's decision, 2026-10-15). The pharmacy app has no maintenance
surface at all — measured on the deployed app as the client tenant's own Business Manager: both maintenance
routes 404, and the GP/dental-shaped nav the fork inherited (Manage Users, Audit Log) is 403 for every
pharmacy role. So the pharmacy tier cannot yet be presented as a managed service with evidence. It is a port,
not a project — the tables are already in the database that app uses, and the client's own admin is identifiable
asPHARMACY_BUSINESS_MANAGER+org_id— and it is specced at Pharmacy App — Client Maintenance
Surface (work order: from-hermes
note 23). The shared-box rule is confirmed on it: the pharmacy tenant shares the dental box, so both tenants see
the same box history, with one line saying the server is shared. Interim by decision (same session): he is arranging a separate Client VPS for the
pharmacy tenant, so the shared shape is temporary and the work for when the box lands (migrate the
pharmacy database and its ERPNext site database, re-point the tenant's tunnel and row, restart the app,
then the build visit) is recorded on Pharmacy App — Client
VPS.
Status: PROPOSAL v0.4, 2026-10-14 — RULINGS COMPLETE for the build: §7.1–§7.5, §9.6 and §10.6 are ruled (§7.6,
commercial, stays open and does not gate the build). WO-A…WO-C are landed and independently verified; WO-D's code half is landed and deployed
(2026-10-15) and its docs half is next on my side; WO-E is not yet raised (see from-hermes). Decided
(Matthew, 2026-10-13): patching the client-VPS estate is part of the managed service, and each client must
be able to see when their VPS was patched, because they have to be able to show it to an inspector or
auditor of their own.
RESOLVED IN CODE, DEPLOYED AND INDEPENDENTLY VERIFIED (2026-10-15) — the client surface now belongs to the
client's own manager/admin. The gate that made every client-scoped view staff-only
(PlatformAdminRequiredMixin) is replaced by a client-practice gate: a tenant linked to a box admits its own
ADMIN/manager roles, VeriPath staff roles come out of the client surface, and the platform views
(/maintenance/,/vps/,/fasthosts/) are staff-only and not client-tier. Verified against the deployed
apps, not the test suite: the client's ownmanager/adminreach the card and/my-maintenance/on
test-client.gp.veripath.co.ukandtest-client-dental.dental.veripath.co.ukand see only their own box's
rows; both are 403 on every platform view and on the platform host; a non-client client role
(RECEPTIONIST,DENTAL_CLINICIAN) is 403 on the client surface and on the bring-forward POST; and a synthetic
row written against another client's box was invisible on every surface and deleted afterwards. The dental app
now has these views (a faithful port — the earlier "no route to the card at all" is closed). Full record:
my note (20) on from-hermes.Three residuals, recorded there and open: (a) on the GP app a client tenant whose own
adminis
is_superuserstill gets the fleet/platform dashboard (is_fleet_view = user.is_superuserruns before the
client branch) — measured: no client card, and other clients' boxes named on the page, while
/my-maintenance/is correct; (b) the dental dashboard has no gate at all (no
PlatformOrClientPracticeRequiredMixin), so aDENTAL_CLINICIANgets200there — no cross-client leak seen,
but the gate is not applied; (c) the dental app'sMaintenanceRecordmirror is missing the real table's
NOT-NULLsourcecolumn, so a create from the dental app fails (read-only today). Still open on this axis:
no client box has cadence rows, so no box's "next run" exists in the app (re-measured 2026-10-15).
RULING (Matthew, 2026-10-15) — the client-facing bring-forward control is WITHDRAWN; the requirement is the
evidence, not a button. Verbatim: "this button serves no purpose which is not a problem — what clients will
need is to be able to see the patching and maintenance that has occurred and can be shown to an inspector for
compliance reasons — I never specifically asked for this button because patching and maintenance of the
client-vps's is part of the managed service that veripath will provide." Reconciliation, so the record is
honest: the control was my addition — method B's "Install now" on this page, carried into WO-D as "the
client's bring-forward button … changes what a client sees on their dashboard". What Matthew ruled in §7.2 was
the restart policy; the client's press appeared there only as the anti-pattern being avoided ("we always wait
for the client to press a button"). It was never a client requirement.What stands, and it is the whole client surface: the client's own record — the maintenance card, the run
list and the two CSV exports (verified live 2026-10-15 as the client's own manager:my-maintenance-records.csv
carriesdate, finished, box, kind, trigger, outcome, applied, outstanding, reboot, summary, evidence_path, and
the package export carriesdate, box, package, from, to, security), so a client can show an inspector what
happened, when, with what outcome, and which packages moved. Patching and maintenance are part of the
managed service — VeriPath runs them; the client's need is the record of them.Consequences. (1) DONE 2026-10-15 — the control is OUT of the client surface on both apps (GP
d524a40, dental2890ab3, both pushed; removed from the client card and the run list, verified live as each
client's own manager: the record and both exports intact, the gate unchanged, the control absent, and the
staff platform views untouched). The job machinery stays but is unreachable from the UI. The docs half of WO-D is DONE (2026-10-15, session 83), awaiting Matthew's review: the
client guide is rewritten around the record and retitled
Understanding Your Maintenance & Updates View. Scope was measured, not assumed — rendering the client
dashboard as each client's own manager shows only the Maintenance & updates card (no health, database,
backup, tunnel, charts or status badge), so the guide documents the card, the run list and the two downloads,
and says plainly that the estate cards are VeriPath's own view. The removed card explanations are kept at
/root/work/client-vps-estate-cards-removed-20261015.mdfor re-homing on a platform-facing page. (2) The next-run date
on the client card drops from a requirement to a VeriPath-facing nicety: a client needs the history, not a
schedule, so computingnext_runis no longer on the client's critical path — though a client page that reads
not scheduled while runs exist should still not be shipped. (3) The bring-forward job machinery stays (it
is how any run can be asked for at all) but is not a client feature; with nothing creating requests, the
missing expiry is dormant hardening, not a live defect. (4) The docs half of WO-D is rewritten around the
record: what VeriPath does on the box, when, and what the client can produce from their own page — written
from the deployed UI once the control is removed.
Companions: Patching & Maintenance on a Client VPS — How It Works
(this design in plain English, for a non-engineer reader, and the base a client-facing notice will be adapted from),
Client VPS (the SIAAS model),
Understanding Your Infrastructure Dashboard (the
client-facing surface this extends), Maintenance Records — In-App View
(the platform-side record this mirrors), DSPT Patching Assessment (gap 5,
now in scope), MinIO → SeaweedFS Replacement — Plan
(the client box runs its own radiograph store; replacing it is WO-M4 there, and it is the live leg).
| Check | Result |
|---|---|
Client VPS registry (infrastructure_clientvps) |
3 rows, 2 boxes — test-client (GP) on test-client-vps; test-client-dental and test-client-pharmacy sharing client-dental-vps |
| What a client box holds | its own PostgreSQL (the client-tier database); the dental box also runs its own radiograph store (dental-radiographs) — SeaweedFS since 2026-10-15, when WO-M4 swapped it in place |
| Patching coverage today | none — no job, no cadence, no reporting path, and no software inventory. patch-host.sh is local-only; the only client-side tooling in veripath-ops is the backup reference script and an inventory recorder |
| Reachability from the primary | the boxes dial out to the primary (reverse tunnels for their database and object-store ports). No ssh alias, forward or listener gives the primary a management path to them |
| OS/package state | unknown from here — it has never been surveyed, which is itself a gap: a patch plan needs an inventory first |
The estate is a test estate, and it stays one (Matthew, 2026-10-15). All three boxes are ours rather than a
customer's — test-client (GP), test-client-dental and test-client-pharmacy — and that is permanent and
deliberate, for two reasons: they are the estate the apps are developed against (future versions included), and
they are the canary, so every patch and every image refresh is proved on them before it reaches a real
client's box. Their access therefore switches between the two postures — SSH while we are developing,
tunnel-only as the in-service posture and for maintenance — rather than being one or the other; the switch is
the access-mode design. A real client's box is
provisioned tunnel-only and never carries a standing login, and provisioning remains one box per client (§6).
For contrast, what the platform tier has and the client tier does not: a monthly OS patch run per host, a
quarterly image refresh, a declared cadence row per job, and a token'd reporter that writes into the app.
/infrastructure/ on a client's own hostname is already theirtenant_id. TheMaintenanceRecord / MaintenanceCadence, the token'd ingest endpoint, thepatch_monthly.sh, patch-host.sh,refresh-image.sh) already exist and already carry package-level detail (detail_json).⚠️ A leak to fix as part of this work. The dashboard's existing maintenance card currently queries
MaintenanceRecord.objects with no tenant filter, so any client practice admin sees the platform hosts
(vps1/vps2/vps3) and their patch state. It is platform metadata, not patient data, but it is not theirs and it
is not what we want them looking at. The card must become client-scoped.
Run the patch on the client box, report outbound (agent model). Recommended because it is the only method
that needs no inbound access — the boxes are deliberately unexposed and dial in — and because it is exactly how
backup already works, so it adds no new transport, no new credentials to hold centrally, and no new failure
mode on the primary.
unattended-upgrades for security updates (the platform host already relies on thissummary.log line and posting aclient_os_patch record through the same endpoint shape.Considered and not recommended (yet): central orchestration. Running the patch from the primary over
WireGuard/SSH would give central control, but it needs a management path into a box whose whole design point
is that it has none, per-box credentials held centrally, and it makes the primary a single point of failure for
every client's maintenance. Revisit only if a client's contract requires us to drive it from our side.
| Element | Proposal |
|---|---|
| Card on their dashboard | "Maintenance & updates" — last run (date, packages applied, how many were security), reboot state, next scheduled run. Same plain-English register as the rest of their dashboard |
| Detail + evidence | a client-scoped record page listing their box's runs, with a download (CSV to start) — the artefact they hand an inspector |
| Scope of visibility | their own VPS only. RBAC must filter by the viewer's tenant; a client must never see another client's box or the platform hosts |
| Provenance | records are generated by the agent's token'd post and are read-only to the client — they can show it, not edit it. That is what makes it evidence rather than a claim |
| Retention | the platform keeps the run history (retention to be set with §7 Q4); the client sees at least the audit-relevant window |
| Optional | a quarterly auto-statement (PDF + checksum) so a client can file it without asking us |
MaintenanceRecord.host and MaintenanceCadence.host are foreign keys to PlatformVPS, which is why no
client box can appear in the platform view — a data-model limit, not a missing run. Proposal:
client_vps = ForeignKey(ClientVPS, null=True, blank=True), make host nullable, and constrain(host|client_vps, kind, started_at);ClientVPS its own ingest token, same constant-time compare, host-local 0600 file;client_os_patch, client_image_refresh) so the two estates never mix/infrastructure/maintenance/ (the platform-wide staff view) at all.test-client-dental and test-client-pharmacy share one box. Where a box serves more than one client, both
clients see the same box history, because it is the same maintenance — the page should say so in one line
rather than let it look like a mistake. Production provisioning should be one box per client, so this stays
an artefact of the test estate.
Cadence — ✅ RULED (Matthew, 2026-10-14): accepted as proposed. Continuous unattended-upgrades for
security updates, a monthly full run per box on a staggered per-box slot outside the client's clinic hours,
and a quarterly refresh of the box's own images (client-tier PostgreSQL, and the dental
radiograph store — SeaweedFS since 2026-10-15; the "build visit" wording this line used to carry was
wrong twice over, which the WO-M4 runbook records). The condition attached was that the shape must actually work when our
only access is the tunnel, and it does: every recurring step runs on the box (the unattended-upgrade
daemon, a local timer, the token'd outbound post), so none of it needs an inbound path, a listener or a
standing login — the platform's own patch-host.sh is the wrong model here precisely because it assumes
local execution. The only step that needs our access is the one-time build visit that installs the
agent (§10), which is the moment §10 already identifies as the only one we hold.
Platform-tier figures the ruling was taken against (verified live 2026-10-14): primary cron 0 2 1 * *
--apply --reboot with unattended-upgrades active, app engine timer 1st 03:00, auxiliary 1st 04:30, image
refresh quarterly on the first Saturday of Jan/Apr/Jul/Oct 03:30. summary.log cadence rows already exist
for all three; the client tier has no job, cadence row or inventory today.
Two specifics were carried to §7.2 rather than assumed, because they are the same decision; both are now
ruled there: the reboot rides the monthly run (with §7.2's notice and ceiling), and the band is the platform's
02:00–04:30 with a per-box minute, because the client box already runs its own backup at 02:30.
Findings (2026-10-15, session 83) — two gaps between this design and the deployed app, both measured.
(i) The client's "next run" cannot be shown yet, and seeding a row would not fix it. Nothing in the code
writes or advancesMaintenanceCadence.next_run— no management command, no signal, no view (only reads and
the admin); the six existing rows are platform-only with hand-set dates, and the client card's
maintenance_next_runis therefore empty, which is why a client's page reads not scheduled while their
box's timer is real. The fix is a computed next run (declared cadence → date, the box's own timer as the
authority), not a hand-set field that goes stale the moment a run happens. (ii) A bring-forward request
never expires: it is created with nonot_before/expires_atand the poll claims null-window rows, so a
press on a box with no agent stays claimable forever and would run unattended the day that box gets an agent.
Both are in work order note (21) on from-hermes, with the button's
behaviour on a box that has no agent.
Reboots — ✅ RULED (Matthew, 2026-10-14): the hybrid policy — never silent, with a hard ceiling on
deferral. A security-critical update that cannot take effect without a restart proceeds in a quiet window
after 7 days' notice; anything else needing a restart waits up to 30 days, then proceeds in the next
quiet window after a second notice. Every restart records the notice date, the outcome, and whether it
was brought forward or deferred; the client sees the state on their own dashboard and the record is
read-only to them. Why hybrid, recorded for the build brief: "we always decide" restarts the practice's
database without their agreement, and "we always wait for the client to press a button" lets an unpatched
box sit for months — the ceiling is what stops the second failure mode while the notice removes the first.
Written up in plain English at
Patching & Maintenance on a Client VPS — How It Works;
the two numbers (7 / 30) are the policy defaults and can be changed per client by contract. Scope note:
a client pressing "install now" is a support request for now — a client-side control is a build question for
WO-C, and the record must show a policy deferral as policy, never as client authorisation.
Method — RULED (delegated, 2026-10-14): the agent model is the baseline (§3, §10.3 method A).
Installed at the build visit, scheduled on the box, reporting outbound with a per-box token. The client's own
"Install now" (method B) is the bring-forward path, not the gate — §7.2's ceiling means a run does not
depend on them pressing anything. Method C (a signed bundle the client runs themselves) stays a fallback
only, for a client who refuses an agent: it makes compliance depend on a human remembering, and records a
gap when they do not. Central orchestration is not adopted: it needs an inbound management path to a box
whose design point is that it has none, per-box credentials held centrally, and it makes one of our hosts a
single point of failure for every client's maintenance — while buying nothing the agent does not already do.
Binding constraint for the build brief: the agent's permitted actions are a fixed, published list
(os_update, image_refresh, inventory, reboot), verified against a signed job, and never a
general-purpose remote shell (§10.2, §10.4).
Client evidence — RULED (delegated, 2026-10-14): CSV and the quarterly statement. The card and detail
page carry the plain sentence; the download is CSV to start; and a **quarterly auto-generated pack (statement
Detail level — RULED (delegated, 2026-10-14): both, each in its place. The dashboard card stays a plain
sentence — "44 updates applied on 12 October, 6 of them security fixes" — and the package-level list lives in
the detail page and in both exports. An auditor can get to the package list; a practice manager never has to
read one.
Commercial — OPEN, and it does not gate the build. Whether patching is charged per box is Matthew's
call, recorded as out of scope for WO-A…WO-D.
| ID | Scope | Owner | Status |
|---|---|---|---|
| WO-A | Model + ingest + RBAC: client dimension on MaintenanceRecord/MaintenanceCadence, per-client token, client-scoped queryset and isolation tests |
OpenCode | LANDED 2026-10-14 — e976d13, migrations 0012/0013 |
| WO-B | The client-box agent in veripath-ops: client-patch.sh + timer + reporter + the signed-job poll, modelled on the client-backup script, installed during the build visit (§10, the only moment we hold access) and including that build-visit runbook |
OpenCode | LANDED 2026-10-14 — control plane 6048a7a/40b6803 (0014), agent cf2462f |
| WO-C | The client-facing card + scoped detail page + export, including the leak fix in §2 | OpenCode | LANDED 2026-10-14 — e853794 |
As built — the first install anywhere, on the canary (2026-10-15). The agent is no longer "the software
we will leave behind": it is running on the dental test-estate box, installed in SSH/dev mode and verified end
to end.
manage.py client_patch_credentials test-client-dental minted the box's ingest token and signed-jobinstall-client-patch.sh installed the agent, its scripts, the four timers and the health unit, andinventory posted outbound through the ingest and landed as aclient_inventory record against that box (outcome success; Ubuntu 26.04 LTS, kernel 7.0.0-28-generic,seaweedfs, client_db and dental_orthanc kept their uptime; theos_update will exercise the notice-and-deferral policy — it isimage_refresh --apply has noClientVPS.generate_password$, while the installer writes the env file as a double-quoted value$ (roughly half of them, by chance) is expanded when the&, which is safe; the fix is to write %q-escaped (or single-quoted) values and to verify the file| WO-D | Provisioning/onboarding: ClientVPSDetailView shows patch state, onboarding runbook and the client dashboard guide updated — plus the client's bring-forward button and the job-completion mark (both change what a client sees on their dashboard, so they belong before the docs are written) | OpenCode + Hermes | CODE LANDED AND DEPLOYED 2026-10-15 (feature/client-patch-state-wo-d: the patch-state detail view, the client's bring-forward button and the job-completion mark; infrastructure app 62 tests OK, 9 new) — the docs half is mine and is the next work order I take — un-parked once the MinIO → SeaweedFS replacement landed its platform half (WO-M1/M2/M3/M5 built and independently verified). The reason for the original hold was that this work's client-box image list changes with that swap; it does not, until WO-M4 runs, and WO-M4's own step 6 carries the list change when it eventually happens — so nothing here waits on it. Order inside the work order: the code lands first, because the button and the completion mark change what a client sees, and the docs are written from the real UI. Code half OpenCode's, docs half mine |
| WO-E | Split out of WO-D at ruling: the quarterly statement pack (§7.4) — a per-client statement + checksum on a schedule, held with their backups; an ops artefact in veripath-ops, not an app page | OpenCode | NOT YET RAISED — deliberately its own work order (a scheduled job with its own failure modes). It was parked behind the MinIO replacement, which is now done (2026-10-15), so it can be raised whenever it is wanted |
Verification (Hermes, 2026-10-14, independent of the handbacks). Migrations 0012–0014 confirmed applied on
the central database; the new client routes deployed and gating exactly as the pre-existing platform view does
(403 unauthenticated rather than 404, checked with the real Host header and over the public interface); the
agent scripts and docs/client-build-visit.md present in veripath-ops at cf2462f; the leak fix read in the
code — a client-tier request is scoped by client_maintenance_for(tenant) while the fleet view is untouched; and
the application suite re-run by hand: 53 tests, all passing, on the WO-C branch against a disposable database.
Adding boxes does not add patch scripts. It adds three problems, in this order, and the patch itself is the
easy one.
The estate-wide view of scaling — and the record of the discussion — is Scaling the
Platform; this section is what scaling means for patching specifically.
Today each client box dials the primary and publishes its database (and object-store) ports as reverse tunnels, and
the app engine reaches them through per-port forwards. That is fine for two boxes and does not scale: every
client adds listeners, a systemd unit, an sshd session, published ports and host-key material on the primary —
and it puts the primary in the data path of every client's database.
At scale the estate needs a private overlay — WireGuard, which the SIAAS model already names and which
the ClientVPS register already carries a field for (tunnel_ip). Boxes then sit on a routed private range:
management and database traffic route rather than being published per client, and the primary stops being a
per-client hop. This is an infrastructure project in its own right and should be sequenced before the fleet
is large, not after — it is the one item on this page that cannot be bolted on later.
| Layer | At a handful of clients | At a few hundred |
|---|---|---|
| Security updates | unattended-upgrades per box |
unchanged — no central involvement at all |
| Monthly full run | a timer per box | timer per box plus a central scheduler that assigns waves |
| Transport | reverse SSH tunnels | WireGuard overlay, routed |
| Agent delivery | installed by hand at onboarding | built into the client provisioning pipeline, so every new box is patch-managed from day one |
| Trigger | the box's own timer | poll-for-work: the agent asks the control plane what to do, so a run can be forced or deferred without any inbound access to the box |
| Rollout | all boxes at once | waves with a health gate — canary (a few) → early → standard, halting the rollout when a wave's boxes stop answering. The gate signals already exist (tunnel health, capacity, app health) |
| Windows | one slot for everyone | per-client window and timezone, jitter, and an automated notice to ClientVPS.contact_email before a reboot |
| Visibility | the maintenance table | an exceptions list (failed runs, reboots outstanding too long, boxes that stopped reporting) — dashboards do not scale eyes, exceptions do |
| Evidence | export per client on request | auto-generated per-client packs on a schedule, held with their backups, so a client's record survives our own degradation |
The single most valuable of those at scale is the wave gate: with 300 clients the risk is not one patch
failing on one box, it is the same bad patch reaching all of them in one night. A canary wave plus an
automatic halt turns a fleet-wide incident into a handful of rebuilds.
The maintenance record itself is small — 300 clients × 12 monthly runs = 3,600 rows a year, package detail
included. The metrics are not: at 15-minute capacity polling, 300 clients is ~28,800 snapshots a day,
~10.5 million rows a year. Set retention and rollup (raw for 30–90 days, hourly/daily aggregates beyond)
before the estate is large, rather than when the table already hurts. The patch data is not the problem; the
heartbeats are.
The sovereign, privately-hosted pitch is the product. A cloud RMM or a vendor patch service puts another
company in the loop and in the data path, and would undercut the reason a client chose SIAAS in the first
place. The scalable, on-brand toolchain is our own control plane (this app) plus open-source, self-hosted
execution (Ansible or equivalent over the overlay) — and for anything that is not the database host, a
rebuild-from-image strategy is more honest than patching a machine nobody will ever look at again.
At hundreds of clients, tenant isolation stops being a nicety: the client-scoping in §2 and §4 is a gate
on this work, not a tidy-up, because one filtering mistake exposes one practice's patch history to another.
The same applies to the RBAC on the platform-wide staff view.
A client's own resources grow with the client and are funded by their fee (their box, their database, their
storage). The platform tier is different: it is shared, and it grows with the whole estate, not with one
client. The triggers and the purchase order for adding VPSs are their own page:
Platform Capacity & Growth Plan. Three VPSs (vps1/vps2/vps3) sized for the current estate is the first thing that has to change as
clients arrive.
| Platform component | What grows with clients | Note |
|---|---|---|
| Central Postgres (vps1) | tenant metadata, capacity/tunnel/backup records, the maintenance record | the patch rows are trivial; the metrics are the growth driver (§9.3). A single instance is the first hard ceiling |
| App tier (vps2) | all client application traffic — their data is on their own box, but the application runs in our tier and reaches their database over the tunnel | scales with total staff users across every client, not with the number of boxes; stateless per request, so it can scale out |
| Tunnel hub / overlay concentrator (vps1) | every client's database traffic transits the hub today | bandwidth = the sum of all client DB traffic, and latency for every clinic. With WireGuard this becomes a bandwidth/HA component, not a pile of listeners |
| Collection & monitoring (vps1) | today the primary polls; at scale the agents must push on their own schedule and the primary only receives | the primary is already the fragile one — the 2026-09-11 CPU incident was monitoring load on it, which is why the Netdata collector is rate-limited |
| Identity (Keycloak) | a realm per client today | cheap per realm, but realm sprawl is an admin ceiling; identity load grows with total users |
| Maintenance plane (the ingest) | trivial load, deliberately | keep it off a saturated primary: if the primary is the degraded component, the patch record is the last thing to lose — hence the per-client packs in §9.2 doubling as capacity insurance |
What vertical growth buys, and where it stops. Bigger VPS1/2/3 is the right immediate step and buys the
first tens of clients cheaply. It does not buy hundreds on its own: a single Postgres, a single app engine and
a single overlay hub are ceilings. The order that usually follows is — vertical now, then a load balancer
fronting two or more app engines (tenant routing already keys off the hostname, so this is mostly packaging),
then the metrics store separated from the transactional record store (or partitioned by month), then replicas
for reads. Each of those is cheaper and safer before the box that needs it is full.
Measure the ratio before sizing. The estate is two client boxes today; as clients arrive, record the
per-client cost — central-DB bytes, app CPU/RAM and connections, tunnel bandwidth, logins — so the sizing is
measured rather than guessed. That ratio is also what the per-client price has to amortise, so it is a
commercial input as much as an engineering one.
The constraint (Matthew, 2026-10-13): for real clients we have access only via the secure tunnel, and the
temporary credential we are given at build time is terminated once we have installed the software. So after
handover we have transport but no login. Everything in §3 assumes we can put a job on the box; this section
is what makes that addressable without holding a key to every client's machine.
| We have | We do not have |
|---|---|
| A private path to the box (the tunnel) | An OS login — no standing root, no password |
| Software we installed while we did have access (that is the whole point of the build visit) | Any ability to log in interactively, at any time, for any client |
| The database credential the application already needs to read their data (a separate, functional secret) | Any listening service on their box that we could connect in to |
So patching cannot be "we log in and run apt". It has to be software we placed during the build, doing the
work on its own, reporting outbound — and the tunnel is only the transport it reports over.
Honest caveat, worth stating plainly rather than overselling: the agent will run with elevated rights on their
box, so we are not "keyless" in effect — we are delegated, and the difference is auditability. Its permitted
actions must be a fixed, published list (update the OS, refresh a listed container image, report inventory,
reboot when required) — never a general-purpose remote shell.
| Method | How it works | Verdict |
|---|---|---|
| A. Autonomous agent (baseline) | Installed at build time. Security updates via unattended-upgrades need no central involvement at all; the monthly full run and image refresh are scheduled locally, and the agent reports each run outbound with a per-box token |
Recommended default. Works with zero standing access, survives the tunnel being down, and every run lands in the record automatically |
| B. Client-authorised trigger (Matthew's "Microsoft Updates" instinct) | Same agent, but the disruptive step waits for the client's own practice admin to press "Install now" on their dashboard (which already exists and already shows them VPS, database, backup and tunnel status) | Recommended for disruptive work — reboots and major upgrades. The record then shows the schedule, the run and their authorisation, which is stronger evidence for their inspection than our silent action, and it keeps them in control of their own downtime |
| C. Signed bundle the client runs themselves | We publish a signed update package to a place they hold (their own object store, or a download); their admin runs one command and the box reports the result | Fallback only, for a client who refuses an agent. It makes compliance depend on a human remembering, and it records a gap when they don't — which their auditor will see. Never the default |
A and B are the same machinery: the box does the work, we schedule it, they can authorise it, and the record
is written either way. That is the whole design in one line.
os_update,image_refresh, inventory, reboot); the agent verifies the signature and refuses anything outside theNo standing access means no remote recovery. If a patch leaves a client's box unbootable or the database
not starting, only someone with a login on that box can fix it — and that someone is the client, not us. An
inspector will ask "what happens if your patch breaks it?", and the honest answer today is "we ask the client
for access". The options to put to Matthew:
Agent model as the baseline — RULED (delegated, 2026-10-14), installed during the build visit, with
unattended-upgrades carrying security updates with no central involvement (§7.3).
Client-authorised disruptive steps — RULED and reconciled (delegated, 2026-10-14): §7.2's hybrid
governs. Method B is not a gate on disruptive work; the "Install now" control is the bring-forward
path, and the 7-day notice plus the 30-day ceiling means a reboot still happens if the practice never
replies. This supersedes §10.3's "recommended for disruptive work" for method B, which is kept as the
mechanism rather than the policy — read that row as "available to them, not required from them".
The recovery path — RULED (delegated, 2026-10-14): layered, and designed in rather than improvised.
(a) Agent self-heal first — a health check after the run and after any reboot, with an automatic revert
where the platform allows it (previous kernel, deferred reboot), so the common failure needs nobody;
(b) a plain-English client-side recovery runbook for their own admin, in the register of their dashboard
guide — what a failed update looks like, what to do, and how to call us; (c) an on-demand temporary
credential as break-glass, the same pattern as the build visit: issued by agreement, used, terminated, and
logged as its own event with its own maintenance record kind, so the client can see when we had access
and why.
Build status 2026-10-14 (WO-B landed): layer (a) exists as a post-run and post-boot health report plus
a restart that is simply not taken again when it has not succeeded — but automatic package rollback is
not built, so a package already applied that leaves the box unhappy is still layers (b)/(c) work. That
difference is recorded on the plain-English page too, rather than left as an implication of this ruling.
Layers (b) and (c) remain outstanding (the client-side runbook is ops; the break-glass credential needs the
temporary_credential kind wired to a record, which WO-A created).
Option 4 (a stored credential under dual control) is rejected, because it recreates exactly the
liability this constraint exists to remove (§10.2).
The build-visit runbook — RULED (delegated, 2026-10-14) as a first-class artefact. It is the only
moment we have hands-on access, so provisioning must install and verify the agent, the backup job, the
reporting config and the client's own dashboard — and record that it did — before the temporary
credential is surrendered. It is a deliverable of WO-B, not a paragraph in a runbook somewhere else.
Not on this page: DSPT assertion numbers. Checked against the current assertion list before anything is
put into an evidence pack — a wrong reference costs more than none.