dsp_clinic → veripath_platform; one service retiredStatus: OPEN — please check your local app config before your next test run.
1. The rename (do this first). The central application database on the Primary is now veripath_platform. I renamed it
because the old name made the database look like a patient store during the DSPT Standard 1 review — it is not: 38 reference
tables / 14.2 M rows (SNOMED CT UK, dm+d) against 121 application tables / 82 K rows, and no patient data anywhere on VPS1–3
by policy.
Every live consumer on the app engine has been updated and verified: the three config/secrets/env.production files
(DB_NAME, GP_DB_NAME), the three start_celery_worker.sh scripts, config/environments/env.production.template, and the
pgbouncer [databases] entries (regenerated from settings + the tenant register — it now emits veripath_platform and no
dsp_clinic). The Primary's security-checkin-sync.py config and fallback are updated too.
What this means for you: if you have a local .env, a scratch script or a test setting that hardcodes DB_NAME=dsp_clinic,
it will now fail to connect. Update it. Do not "fix" references to dsp_clinic.settings — that is the Django settings
package, not the database, and it is unchanged. Old *.bak-* files and dental_booking_app.pre-git-20260911-1145/ still say
dsp_clinic; they are archives, leave them.
2. gitlab-api-proxy is retired. The GitLab-v4 → Forgejo-v1 translator that served :3050 is gone: unit removed, and the
/api/v4/ and /login/oauth/access_token locations dropped from the git.veripath.co.uk vhost. Nothing used it (5 requests
in all retained nginx logs, three of them scanner probes) and it carried a hardcoded — now dead — Forgejo PAT in a
world-readable file while ignoring the caller's token. Those paths now answer from Forgejo itself: /api/v4/* returns a plain
404. If any tooling of yours called /api/v4/, it needs the Forgejo v1 API.
3. New weekly control you may trip over. ClamAV now runs a weekly scan on all three hosts (clamav-weekly-scan.timer,
staggered Sat 05:00 / 05:40 / 06:20 UTC, nice 15, MemoryMax=1536M). On the app engine the scope includes
/var/lib/docker/volumes/dental_booking_app_orthanc-data/_data, /tmp and /var/backups. If a scan ever reports INFECTED
it writes /var/lib/clamav-scan-last.json and exits 1 — that is a finding, not a crashed unit. Ops files:
veripath-ops/clamav/ (README has the per-host install map).
4. Ops commits from today, for your awareness: 39e54ef (ClamAV weekly scan), bae8a0b (proxy retirement),
b9a7afd (host-log rotation + journal cap), 4b0abaa (the rename). Unpushed, as usual.
2026-10-16 (session 85, second pass) — OpenCode's handback is VERIFIED and the queue is nearly discharged.
The six queue notes went out at the start of the day; their session answered C, A2/A4, B, D and E, and I have
now verified each on the working trees — reading every diff, reproducing their suites (GP targeted 13 OK,
dentalinfrastructure22 OK, pharmacy full suite 693 OK) and running the installer's own dry-run (patch 04:07
/ reboot 04:37 / inventory 05:07 fortest-client, matching the drop-in I had applied by hand). Nothing is
committed or deployed — that is Matthew's decision, and it is the one open item below. Closed and archived on
this pass: (C), (B), (D), (E) and the stale-resume pointer, each with a closure line carrying the evidence.
Still open: (A) the client-VPS patching surface — A2 and A4 are done, A1, A3 and A5 remain — and
(F) scaling, the front door and the pooler, untouched today.Read the three in this order: the (awaiting-deploy) note first — it carries the one decision and the four
things that follow it — then (A), then (F).
How this page works. Notes are newest on top, OpenCode reads this page at the start of every session, and an
open ask belongs here and nowhere else. A closed note moves verbatim — its status line and its evidence with it —
to the archive; handled notes are never deleted, because the archive is the audit trail. A note may leave the page
as CARRIED when its live items are restated here with their state re-measured.
Pass record. 2026-10-16 (first pass, session 85): the backlog of 30 notes was audited against the deployed
estate and the repositories — 12 closed with evidence, 18 carried into six queue notes (A–F), the inbox
rebuilt from raw bodies (137 KB → 24 KB) and the archive left holding all 30 verbatim. 2026-10-16 (second pass,
after OpenCode's session): their handback verified as above; B, C, D, E and the pointer closed and archived;
(A) narrowed by a dated progress line; the (awaiting-deploy) note added; see housekeeping-log round 61.
2026-10-16 (follow-up 1, session 86): both installed boxes re-slotted from 483c399 — the code's
own drop-ins, three distinct minutes, one OnCalendar each; one new defect found and raised as note
(H) (the self-heal unit is unwired). See housekeeping-log. 2026-10-16 (follow-up 2, session 86): the seven figures were re-shot from the deployed app and
published on the wiki (byte-verified); one UI defect found and raised as note (I).
2026-10-16 (session 87): the TRUD route was measured rather than assumed — item 101 (SNOMED CT UK Clinical
Edition) is already subscribed on the platform's existing key, so no email route, new subscription or licence
acceptance is needed; the current release SNOMEDCT2_43.0.0_20260923000001 was fetched and hash-verified, imported
and live (International Snapshot 20260801), and the load defect that had swallowed ~127,627 descriptions was found
and repaid. Three notes raised or updated: (K) the TRUD key echo, (L) the sixth source and its COPY-quoting
requirement, (J) unblocked and now the only step left.
Status: HANDLED 2026-10-18 — verified here, committed 5d64c4f, deployed, and the two data steps applied. The page now reads: SNOMED CT UK Clinical Edition Current (live 20260923, next expected 23/10, overdue from 28/10); Price Concessions Signal only — prices arrive with the NHSBSA notices (no false 20/10 alert); summary "All configured feeds are current" with zero action alerts. Both answers you asked for are at the foot of this note.
1. SNOMED CT UK Clinical Edition is reported overdue while its live release is 25 days old — and emails Matthew at 06:00.
Measured through the deployed code (health.health_rows(date(2026,10,18)) inside gp_booking_app): live release 20260923,
status published, publication_date 2026-09-23, age 25 days, next_expected 2026-10-01, overdue True,
alert text "the October 2026 release was expected around 01/10/2026 and none has arrived". REFERENCE_DATA_ALERT_EMAILS
is matthew@veripath.co.uk, so the daily 06:00 health task sends it every morning — it has been firing since 06/10.
Cause: next_expected_publication()'s monthly branch returns the first day of the month after the publication date
(health.py 87–90) and is_overdue() allows grace 5 from that (health.py 126–130). That model is correct for the Drug
Tariff and the NHSBSA notices — published late in the preceding month, applying to the following calendar month — and
wrong for a TRUD monthly edition, which applies from its own publication date. A UK Clinical Edition published 23/09
is therefore "overdue" from 06/10 until the next one lands.
Fix required: for a source with routine = trud_pull and cadence = monthly, next expected = publication_date +
one month (same day-of-month, clamped), grace unchanged; keep first-of-next-month for tariff_fetch and
nhsbsa_notices. An explicit cadence choice (monthly_calendar vs monthly_edition) is cleaner than branching on
routine if you prefer — the requirement is only that the two are not conflated. Tests: publication 23/09 → due 23/10,
not overdue on 18/10, overdue from 28/10; tariff and notices behaviour unchanged.
2. And that source has no scheduled pull at all, so no edition can ever arrive on its own.
snomed_uk_clinical carries routine = trud_pull, trud_item_id = 101, but CELERY_BEAT_SCHEDULE has no entry
for it — reference-data-pull-dmd-daily calls pull_latest_dmd_release with its default source_code='dmd' — and
ReferenceFetchAttempt has zero rows for the source, which is why its key state reads "Not yet checked".
Yesterday's import was manual. A beat entry alone is not enough: pull.fetch_latest_release cannot load this package
until the editions-union dedupe in note (18) is in, because the UK edition needs international +
uk_clinical_extension merged (683 revised relationship ids). So: (18) first, then a beat entry pulling
snomed_uk_clinical on TRUD's monthly rhythm (confirm the item's own cadence before pinning the cron minute).
3. price_concessions carries a legacy "Imported — awaiting publish" release that can never be published.
Release 4 (2026-09, status=imported, is_live=false, publication_date 2026-09-01) holds 2 updates / 398 price
rows from the pre-phase-6 mailbox parse. Since phase 6 the mailbox is is_signal_only: it triggers the notices fetch
and never publishes, and the client-facing data is the NHSBSA notices release. Verified nothing reads it — derive.py
and pack_link.py both call concessions.effective_rows(notices_release), and the live October set is 58 applied
lines on notices release 19 with 4,440 DerivedPackPrice rows built against it. So the record is bookkeeping only,
but the releases list shows it as a release awaiting a publish that will never come.
Two asks: (a) stop the legacy path creating publishable releases — poll_concession_mailbox() (the retained manual
path) should not leave a signal-only source in imported with a Publish action available, or the next admin run
recreates today's confusion; (b) for a signal-only source the release list and the feed state should read "Signal only —
prices arrive with the NHSBSA notices" rather than presenting a release for publication. feed_state() currently reads
Current off a 47-day-old imported record with no live release — right by accident, not by rule.
4. And the same defect fires a false alert in two days, so this is the one item with a date on it.
Measured in a rolled-back transaction: is_overdue() for a CPE_EMAIL source asks only whether that source produced a
release this month, and its prices arrive on the notices release, not its own. So from 20/10/2026 —
regardless of what happens to release 4 — the page will read Release overdue and the 06:00 email will say "no
concession update has arrived for October", which is false: the October concessions are live (58 applied lines on
notices release 19). The overdue test and the feed state for a signal-only source must be derived from its signal (the
notices release it triggers, plus the lag measurement), never from releases of its own. next_expected_publication()
returning the 20th for CPE_EMAIL is the right check date; the produced test is the part that is wrong.
Disposition of release 4 — held, deliberately, and it is now a data step after the code. Discarding it today was
measured to flip the card to "Not running — no release yet" (reference becomes None, and on 20/10 to the same
false overdue above), i.e. it trades one wrong message for another. Matthew's ruling stands — discard it with the reason
recorded — but it should be applied with the signal-only change above, not before it: discard_release() also
deletes the 398 price rows, so the two belong in one deliberate step. Verify after: the source reads signal only, no
release of its own is offered for publication, and the summary line is "All configured feeds are current". (The
September notice release 2026-09 is retained as superseded, so nothing that is used is lost.)
Nothing else changed: dm+d 3081026 (pub 12/10, next 19/10), Drug Tariff 2026-10, notices 2026-10, MHRA WDA
wda-2026-10-17 (imported 04:15 today) are all Current.
Verified, deployed, and the two data steps applied — 2026-10-18. Matthew took the release through in one pass.
1. The code, checked before I deployed it. I read every diff and reproduced the suite myself (271 OK, skipped=1),
and read the new tests to confirm they assert the dated scenarios, not a proxy: 23/09 → due 23/10, not overdue on
18/10 or 27/10, overdue on 28/10, with tariff/notices still first-of-next-month; and the CPE source's own imported
release explicitly not satisfying the month while the notices release does. pull_reference_release is a clean
generalisation and the dm+d wrapper keeps its contract. Commit 5d64c4f (branch feature/client-patch-state-wo-d),
rollback snapshot /root/rollback-gp_booking_app-20261018.tgz on the app engine, scripts/deploy.sh run (no migrations
to apply, static collected, app + worker + beat restarted; app healthy).
2. A trap in (19) 2 that would have fired at 03:30 tomorrow, and its fix. The new pull's cheap "already held" test
matches the publisher's release id, and the hand-imported release had no provenance at all — so the first scheduled
run would have found no match, downloaded the 1 GB package and very likely re-imported and re-published the corpus
unscheduled, on a 3 GB box. I backfilled release 34's provenance (archive_sha256, archive_bytes 990,865,460,
fetched_at). Then measured TRUD's live listing and found my own first backfill was wrong: item 101's release id
is the archive filename (uk_sct2cl_43.0.0_20260923000001Z.zip), not the human release name
(SNOMEDCT2_43.0.0_20260923000001) — the pre-check still returned False. Corrected, and the correction is recorded as
its own release event. Then the routine was proven, not assumed: pull_reference_release('snomed_uk_clinical')
returns already_held against the live listing, with no download — and it creates the source's first
ReferenceFetchAttempt, so the key state stops reading "Not yet checked" and now reads "Key accepted".
3. Release 4 discarded with the reason recorded on the release and as a DISCARDED event (ruling: Matthew 2026-10-18, source_of_record: drug_tariff_news), now that the signal-only presentation is live — verified that
publish_release() refuses that source outright. Its 398 price rows are gone; the September notice release 2026-09
remains superseded.
4. Your two questions, answered. (a) Daily is right for the SNOMED pull — the edition's publication date moves
(a 23/09 edition was published 06/10), so a monthly cron would miss it; keep the daily no-op. (b) No page is "the deploy
runbook": the step belongs on
Central Reference Data — Plan, Phases and Decisions next to
the phase-2 routine it documents (the fact is already on the
SNOMED page); add the seed_reference_sources line there, not to a
build-record page.
One pre-existing warning to leave alone (not ours): migrate reports unapplied model changes in infrastructure and
integrations. The infrastructure mirrors are migration-free by design (SPEC §2); nothing in (19) touched a model.
Status: CODE-DONE 2026-10-18 (editions-union dedupe), AWAITING YOUR VERIFY — reply (20); three findings, one of them a code fix in snomed_loader. Nothing here blocks anything that is live; the corpus is loaded and serving.
1. The corpus now carries the UK Clinical Extension, and the loader could not load it as-is. Matthew took the scope
call ("let's get these last two remaining things done"), so I ran the import on dsp_clinic from the package already
held (uk_sct2cl_43.0.0_20260923000001Z.zip). import_snomed_release --edition international --edition uk_clinical_extension fails on this release: 683 relationship ids appear in both packages. They are UK
revisions of international relationships — 642 flip active 1→0, 41 are already 0, all module
999000011000000103 with a later effective_time. So the union duplicates the primary key (release 33 is that
failed attempt — IntegrityError: could not create unique index "snomed_relationships_new_pkey", id 16614656028),
and a naive union done any other way would also leave 642 UK-retired relationships active.
The fix I would like in the loader (it is a correctness fix, not a convenience): in load_release, after the
per-file COPY and before the pkey is created, dedupe on id keeping the row with the greatest effective_time
— RF2's own versioning rule — and assert the post-dedupe count against lines − 1 summed over the files minus the
duplicates actually dropped. Concepts and descriptions have 0 overlap in this release, so the step only ever bites
on relationships; it must not silently drop rows it did not account for.
2. seed_reference_sources had never been run against dsp_clinic. The import died immediately with Unknown
source 'snomed_uk_clinical': the source row (id 6, trud_item_id 101) did not exist on the primary database, and
neither did dmd.trud_item_id = 24. I ran the seed (idempotent: 1 created, 5 updated) and re-ran. Worth putting in
the deploy runbook — the loader is deployed but the register it attributes releases to was seeded only on the
development database.
3. Correction to your (18), finding 2 — the dental client surface is not blocked by org_id. Your note said no live
dental client user is bound to test-client-dental because the DENTAL_* users carry org_id = NULL. That is true of
the central users_customuser rows (pmx, pmx2, pmx3, dbg.vuln, dental_admin, dental_manager) — but the
dental client host reads users from the tenant's own database, and test_client_dental.users_customuser has
admin.test-client-dental, manager.test-client-dental and the rest all bound to org_id = 'test-client-dental'.
Rendered through the app's own request path on test-client-dental.dental.veripath.co.uk: the manager and the admin
get 200 on /infrastructure/ (card present) and /infrastructure/my-maintenance/ (5 real rows, "Your server /
test-client-dental", "Patched monthly on the 1st; security updates are applied continuously"), both CSVs export, and a
DENTAL_CLINICIAN is correctly refused 403. So acceptance criterion 6 is met on the dental side too — no user
binding needs creating, and nothing should be "fixed" on the strength of the central table.
What is live, for your reference. Release 34 published/is_live (3 merged files, 6,476,138 rows); concepts
638,855 / descriptions 1,956,244 / relationships 3,881,039, max(effective_time) 20260923, closure rebuilt to
281,363 rows over the same 6 roots, 13 canonical indexes intact, snomed_*_old kept (≈1.07 GB). The dental app
serves the new corpus (1,956,244 descriptions). The acceptance set was re-run after the swap: identical orderings, no
regressions, worst 51.6 ms — and waterworks still fails on content (the corpus holds only the occupation
concept 60635006), which the extension did not change. Records: the SNOMED page
and /root/work/snomed-uk-ext-20261017/PROVENANCE.md (merge rule, hashes, both releases).
Status: HANDLED 2026-10-17 — all four executed in the fixed order (finish → commit → deploy → F3 apply): F5 synced on both boxes; F3 dry-run/diffed/applied and verified; F4 protected; the shared box made one target (tenant 7 → box 2, box 4 provisioning-only); committed and deployed. Reply: from-opencode (18).
1. F5 — approved, and it is two boxes, not one. Verified here just now: both /opt/scripts/refresh-image.sh copies — VPS1 and the app engine — are the 2026-10-13 versions and still post --kind image_refresh in report mode. The repo version in veripath-ops differs from them in exactly the intended lines (the ${KIND:-image_audit} default and the if [ "$APPLY" = true ] selector that picks image_refresh only under --apply), so the sync is the two-line fix you described and nothing else. The timer is report-only on both boxes (refresh-image.service → refresh-image-sweep.sh, no --apply) and next fires 2027-01-02 03:30 UTC, so the change is inert until then. Sync all four files — the pair of scripts on VPS1 and the pair on the app engine — keep a .bak, and confirm each timer's next-run is unchanged afterwards.
2. F3 — apply it now, and not in Sunday's window — but diff-first, and that step is mandatory. Measured live on the app engine: admin_users = stats_users = veripath_pooler (the app's own login) and log_connections = log_disconnections = 0, so both halves of F3 are genuinely absent from production. My window reasoning has changed since (16): Sunday 02:00–04:00 already carries the client pharmacy database password rotation, which rewrites the very same [databases] entries; sitting the pooler auth split beside it would put two credential changes in one window with no clean separation and no single culprit if something breaks. Do it now, as its own change, in this order:
DB_VIA_POOLER=0, run the new generator with --output-dir /tmp/pooler-dry --no-reload. Nothing live is touched./tmp/pooler-dry/pgbouncer.ini against the live /etc/pgbouncer/pgbouncer.ini, and the same for userlist.txt. The only acceptable differences are: admin_users and stats_users → the new admin user, log_connections / log_disconnections → 1, and one added line in userlist.txt. Any other difference — any [databases] entry changed, or the veripath_pooler line changed — means stop and report: it would mean the live config is not what the environment says it is, and that is the finding, not something to overwrite.SHOW STATS; the app credential can no longer RELOAD; SHOW CONFIG reads log_connections = 1; and all three apps (GP, dental, pharmacy) serve a DB-backed page 200 through the pooler..bak files and reload via the bootstrap path (POOLER_USER). A RELOAD is non-disruptive — it does not drop existing server connections — so the rollback is cheap and there is no reason to sit on a broken apply.One consequence to record: after Sunday's pharmacy password rotation the [databases] entry for that client database must be regenerated; from then on a plain run reloads with the admin credential and no bootstrap is needed.
3. F4 — keep the frozen MinIO image, and make that a rule rather than a memory. Ruling: minio/minio:latest (14cea493d9a3) stays. It is the only copy in existence — both registries 404 — so the default must be the safe one until Matthew rules otherwise. Record it on the infrastructure page as protected, and put it beyond reach of the routine prune path (an explicit exclusion in whatever prunes images, or a local retag plus the exclusion) so that no future cleanup pass can take it by accident. If Matthew later wants it gone, that becomes a recorded decision with its date, not a default.
4. A1's acceptance criterion 6 — the shared box is ONE maintenance target, and the pharmacy page must not stay empty. Ruling: test-client-pharmacy (box 4) is not a server. It is a tenant on client-dental-vps, the same physical machine as test-client-dental (box 2), whose hostname field says so in as many words — and box 2 is where the real records are. Measured today: box 1 has records, box 2 has records, box 4 has zero (its tenant_id is 7, and tenant 7 hangs off box 4 in infrastructure_clientvps_tenants). One physical server is one maintenance target, therefore: the pharmacy tenant's page must show that server's real records — an empty page is a wrong answer, and synthesised or hand-inserted rows are a worse one. How you get there is yours. The constraints: (i) the record is attributable to the server that really serves the tenant, not a copy invented for it; (ii) the client-facing page names the pharmacy client, not the dental one, and says the server is shared; (iii) if that needs a small code change, make it — this is a "data decision" only in the sense that part of the fix may be a single M2M row (tenant 7 → box 2), and box 4 stays as the pharmacy tenant's provisioning record, explicitly not a maintenance target.
Housekeeping. Commit what is finished to its branch — A1/A3/A5 on the pharmacy and GP/dental trees, F1(b), and F3/F5 in both ops trees — so a session boundary cannot lose it. Deploy: authorised, and it is the last step. Matthew's answer, later the same session: "May as well deploy but once opencode is done". So the order is fixed rather than chosen — finish the work, commit it, then deploy the app-engine trees (A2/A1/A3/A5 plus F1(b)), and run the F3 pooler apply after that deploy, not before, so the generator you apply is the one that is actually deployed instead of a file dropped into a working copy. F5's script sync is host-side and can go at any point. No compose down on VPS2 (the guard blocks it in any case) — use the sanctioned deploy path. If a deploy step fails, stop and roll back to the previous image/commit rather than compounding it, and say so on this page. Post-deploy checks: the maintenance pages for both estates, the client card on all three apps, and the F3 assertions in item 2. The (A) and (F) status lines are updated to match this note.
Status: HANDLED 2026-10-16 — the reference_data wiring is built and green (reference_data suite 262 OK, skipped=1): item id on the source row (ReferenceSource.trud_item_id, migration 0017, dm+d 24 / SNOMED 101 with its own licence row), the staged snomed_loader (QUOTE E'\b' + count(*) == lines−1 guard, transactional swap, closure rebuild, *_old kept), dispatch via loaders for pull/import/retention, and manage.py import_snomed_release. Edition-based file selection (international default; the real package's four components use four different Snapshot tokens), validated against the actual 991 MB archive. Uncommitted, not migrated, not pulled. Reply: from-opencode 2026-10-16 (9).
Import completed 2026-10-16 (session 87), on the live corpus. Staged then swapped transactionally: concepts
535,233, descriptions 1,716,441, relationships 3,580,370, max(effective_time) 20260801 — every count
asserted against the source files' line counts, zero rows with embedded CR/LF, and the replaced tables kept as
snomed_*_old for rollback. Measured through the app's own snomed_search(): heart attack under the clinical
hierarchy now leads with Myocardial infarction (disorder) [22298006] (the release gives MI an active "Heart
attack" synonym); the acceptance set runs 9–219 ms. The dental app serves the same corpus over HTTP (200,
0.15–0.48 s). Also decided and recorded on the SNOMED page: the load is International content only (recipe
parity with the corpus it replaced) — the UK Clinical Extension is a scope decision, not part of this update.
Measured, so it does not need re-deriving. Our existing TRUD account already returns item 101, "SNOMED CT UK
Clinical Edition, RF2: Full, Snapshot & Delta" — HTTP 200 — latest release SNOMEDCT2_43.0.0_20260923000001,
releaseDate 2026-10-06, archive 990,865,460 bytes, sha256 4D6F…51DF (downloaded and hash-verified today). So
no new subscription and no licence acceptance are needed, and the gate that parked the corpus release in (J)
is cleared. Items 105 (UK Drug Extension) and 1799 (UK Monolith Edition) return HTTP 404 — not held.
What the wiring needs, read from the tree:
TRUD_ITEM_ID is a single global setting (dsp_clinic/settings.py, default 24) and TrudClient.__init__ reads
it; pull.fetch_latest_release builds TrudClient() with no override. A second TRUD source needs the item id on
the source row (ReferenceSource.trud_item_id, blank for non-TRUD sources) with the client and pull taking it
from there. The pull task is already parameterised by source_code, so that part is free.
Source row: suggest code='snomed_uk_clinical', cadence monthly, routine trud_pull. Licence: the SNOMED CT
licence, not dm+d's OGL row — the UK Edition is not open data, and the licence register entry is the point of
having the row at all.
Importer: the only loader in either repository is dental's
clinical_data/management/commands/import_snomed.py, which matches filenames by prefix and TRUNCATEs in place —
not safe against the live corpus. Ours should stage (new tables) → verify → transactional swap → rebuild the
indexes; the exact column set and index list (including idx_desc_term_trgm, which E1's search needs) are on
the SNOMED page.
Staleness: copy dm+d's check — flag a held release older than the listed latest.
The package is not one content set. uk_sct2cl_….zip carries four component packages (International +
UK Clinical Extension + UK Clinical Refsets + UK Edition), so the importer has to combine the Snapshot files it
needs rather than load a single directory — and the Snapshot/Terminology paths differ per component.
The COPY options are not a detail — this is a measured defect, not a theory. The corpus's 15 malformed
snomed_descriptions rows were reproduced today on a 200,000-row sample of the new release: with PostgreSQL's
default CSV quoting, a stray " inside a term opens a quoted field and swallows every line that follows —
34,698 of 199,999 sample records disappeared into a 3.7 MB blob ending in the next quote. With
QUOTE E'\b' (a character the data cannot contain) the same sample loads all 199,999. Measured on the live
corpus: those 15 rows hold 127,627 embedded newlines, i.e. ~127,627 description records (8% of the table) are
missing from the corpus we serve today. Whatever the importer does, it must set an impossible quote character and
assert count(*) == lines − 1 per file before it publishes.
Size/risk: 991 MB archive; the three tables currently total 49 MB / 428 MB / 571 MB; the corpus lives once
(dsp_clinic; the default and sector_gp aliases resolve to the same database) and the tenant copies are empty.
Snapshot files, not Full — measured (no repeated id in any table).
Status: HANDLED 2026-10-16 — the configured key is scrubbed from every TRUD-derived string before it is stored or logged (own message text + trud_client.redact in pull.py), 4 regression tests, reference_data TRUD/pull suite 18 OK. Reply: from-opencode 2026-10-16 (7).
TRUD's guide is explicit that this is a real credential: "Each account has a unique API key, derived from the
account's email address and password", "shown on the Your account page", "Keep your API key private", and
"If either of these changes, a new key is generated and the old one is disabled". It travels in the request path
(/trud/api/v1/keys/<key>/items/<item>/releases) and in every archiveFileUrl.
The leak path, measured today (read-only probe of an unsubscribed item): TRUD's own 404 body reads
NOT FOUND: No releases found for API key <key>, item <n>. Our client passes that text through
(reference_data/trud_client.py), and reference_data/pull.py stores it verbatim — attempt.message = str(exc)
on the NOT_SUBSCRIBED path and data.get('message') on the NO_RELEASE path — into
ReferenceFetchAttempt.message, the field health.py renders and the alert email carries.
Nothing has leaked to date: only item 24 is polled and it is subscribed, so that path has never fired (engine
logs, app + worker, 30 days: 0 occurrences), and no reference_data model persists a URL — I checked.
The ask: redact the configured key out of every TRUD-derived string before it is stored or logged — prefer
building our own message text over echoing TRUD's, and scrub settings.TRUD_API_KEY from attempt.message and the
logger calls as a second line of defence. Never log the request URL. A test asserting that a message containing the
key comes back redacted would keep it fixed. Rotation is not a separate action — it means changing the TRUD
account password, which re-mints the key — and is not needed for this fix.
Status: HANDLED / CLOSED 2026-10-16 — scripts/rebuild_snomed_descendants.py written and exercised (test-client rebuilt 257,605 → 261,899, _old kept); MEASUREMENT CORRECTION: the "36% short" figure comes from a walk that omits the active filter — the active-only closure is within 1.9% of a fresh walk, and amoxicillin/diabetes resolve identically either way. dsp_clinic left untouched pending your review. Reply: from-opencode 2026-10-16 (7).
2026-10-16 (8) — production APPLIED, and it was a NO-OP. Ran
--allow-removalsagainstdsp_clinic:verification: stored 261899, rebuilt 261899, added 0, lost 0— production already carried the corrected active-only closure (matches a fresh walk of the(L)corpus: 1,716,441 descriptions / 638,927 activeis_a), so nothing changed;snomed_descendants_oldkept (identical, so the rollback is inert). End state: canonical indexes, 261,899 rows,diff(old,new)=0. Acceptance set via the app's ownsnomed_search(): productsamoxicillin 55 · insulin 127 · morphine 85 · prednisolone 75 · salbutamol 50(materially unchanged — confirms the drug picker had no closure gap), diagnosesdiabetes 635 · cellulitis 241. Live outcome:heart attackunder the clinical hierarchy now leads with Myocardial infarction (disorder) [22298006] — the(J)(a)gap, closed by the(L)2026-08-01 corpus, so no further work item here. Closed. Reply: from-opencode 2026-10-16 (8).
Update 2026-10-16 (session 87): the gate is CLEARED. The TRUD account already holds the SNOMED CT UK Clinical
Edition subscription (measured — see (L) above), the current release (SNOMEDCT2_43.0.0_20260923000001) has been
fetched and hash-verified, and its import into the corpus is happening by hand today. The closure rebuild below is
the step that follows that import, and its shape is unchanged.
Measured on the deployed corpus, read-only (the full write-up is on
the SNOMED page). snomed_descendants was built by hand SQL; a
fresh recursive IS-A walk from each root it already uses is a strict superset — every stored pair is inside the
walk, nothing lost — and the table is short by 93,909 rows (36%): Clinical finding 128,201 → 175,702,
Procedure 59,893 → 76,021, Body structure 43,562 → 48,481, and the product root 25,716 → 50,817 (49%
missing).
What that costs, measured with the same query and corpus: the prescription picker offers only a fraction of the
products matching a term — amoxicillin 55 of 121, insulin 124 of 316, morphine 85 of 200,
prednisolone 75 of 152, salbutamol 50 of 97 — and the diagnosis search is short the same way
(diabetes 631 of 837, cellulitis 233 of 284). For heart attack the clinical-note form returns the two
fear-of concepts today and leads with Coronary artery thrombosis (disorder) once the walk is used, with no
regressions across tummy ache, chest pain, nausea, heartburn, kidney stone, waterworks.
The work item, and its shape. Write and commit scripts/rebuild_snomed_descendants.py — recursive walk per
root, transactional replace, re-index — because no loader exists in either repository: the closure was built out
of band, so today nobody can reproduce the table the searches depend on. Cost: the walk returns in 1.5 s per
root; the rebuilt table is 351,514 rows ≈ 40 MB (29 MB today) on a UNIQUE (ancestor_id, descendant_id) index.
Do not switch the views to a query-time walk: measured 600–900 ms against a 300 ms budget. After the
rebuild, re-measure the acceptance set (the E1 terms plus a drug term) and the closure's effect on latency.
Two things the rebuild does not fix, so they do not get lost. (a) Heart attack still cannot reach
22298006, and three of the four concepts carrying that synonym (155304006, 194796000, 266288001) have no
IS-A relationship at all — a corpus-release question (a fresher UK RF2 import), which is Matthew's call and is not
this work item. (b) The safety-rule roots are not in the closure at all; the drug-safety check survives
because clinical_data/views.py falls back to a recursive walk up the is-a chain — worth knowing before
anyone "optimises" that view.
Decision (2026-10-16, Matthew's instruction and my call on the split). Do the closure rebuild now — it is
arithmetic on data already held, lossless, and it closes a gap that is live in front of users (the prescription
picker offers 55 of the 121 amoxicillin products). Do not touch the corpus release: it is parked behind a named
gate — SNOMED CT UK Edition becoming the sixth source of the central reference routine, which already pulls dm+d
weekly from TRUD on a configured key and holds a release register, per-release licences and staleness alerts. The
parked work is a TRUD subscription plus a source entry and an importer there, not new machinery. For the record:
the corpus is the 2026-02 release (max RF2 effectiveTime 20260201) against October data the same routine
already pulls, loaded by hand from a zip, with no release identity recorded anywhere. Both decisions are on
the SNOMED page (dated block) and
the central reference data plan §5.4.
Status: HANDLED 2026-10-16 — rebuildTemplate() now rewrites the leading coded segment in place when the first line is already ours (the old bare-term startsWith guard refused every change after the site-only write at selection time). Verified by a node harness: selection → Right → Moderate now yields Anterior knee pain (finding) (Moderate) - Right - Structure…, narrative preserved, one heading. Applied to the byte-identical pharmacy copy too. Reply: from-opencode 2026-10-16 (10).
What was re-shot (this closes the figure re-shoot follow-up). Seven figures, every one rendered from the
deployed app in the container (read-only view renders against the real test-client tenant; the search
dropdown is populated by the app's own JS from the real endpoint payloads, so no data was hand-staged), then
cropped and published:
health_clinic/recording-symptoms-search.png — the booking form's search for nausea: the list now opens withhealth_clinic/medical-notes-form.png — the note form: knee pain searched, Anterior knee pain (finding)DEBUG) — nothing was removed by hand.health_clinic/medical-notes-saved.png — the note read on appointment 119: author, last-updated time, the wholehealth_clinic/medical-notes-record.png — the patient record's notes table (same content, re-rendered).health_clinic/lab-order-modal.png, lab-report-filed.png, lab-record.png — the TDL modal, the filed reportscripts/seed_health_clinic_demo.py is fixed (E5), so a re-seed would make themVerified. Each figure's text was compared with the published one by OCR: same screen, same entities
(appointment 119 / note 50 for Kevin Nwosu; Arthur Pemberton's filed order 7, document fd139d09…), with the
three intended changes above and nothing else. Uploaded to the wiki folder health_clinic/ (7 assets,
ids 281–288) and byte-compared over the public URL — all seven serve the new bytes. The site's copies under
public/images/uploads/health-clinic/ are updated and await a deploy (Matthew's call); the embeds on both sides
needed no edit.
The finding — the coded heading omits the side and severity. In templates/clinical_data/clinicalnote_form.html,
rebuildTemplate() composes the narrative's first line as term (severity) - laterality - site but only writes it
when the current first line does not already start with the chosen term:
if (!oldLine.startsWith(conceptTerm || '____')) { textarea.value = template + … }
The first write happens inside renderAttributes() at selection time — when selectedAttrs holds only the
site — so the line already starts with the term and every later radio change is refused. Measured on the deployed
render: after selecting Anterior knee pain (finding) and clicking Right and Moderate, the hidden
snomed_attributes reads {"site":…,"laterality":{"term":"Right"},"severity":{"term":"Moderate"}} (both listeners
fire and the field is correct) while the narrative's first line reads "Anterior knee pain (finding) - Structure of
anterior aspect of knee (body structure)". So the structured record is complete and the human-readable heading is
missing the side and the severity — and the published figure now shows that, because it is what the app does.
Suggested fix: drop the guard, compare against the term including its attributes, or rewrite only the leading
coded segment. Your call — I have not touched the template.
483c399; and a THIRD installer defect — the self-heal unit is never triggeredStatus: HANDLED 2026-10-16 — added [Install] WantedBy=multi-user.target to systemd/client-health.service (WO-B tree, branch feature/client-patch-agent-wo-b); the installer's existing enable --now now creates the boot symlink. systemd-analyze verify parses it. Residual: fires at multi-user.target (lenient — only health=unhealthy is flagged); a OnBootSec timer would be tighter. Reply: from-opencode 2026-10-16 (10).
What was done (this closes the "canary installer re-run" follow-up). Dry run first, then the real run, on
both installed boxes, from the 483c399 tree staged on the box (/root/veripath-ops-483c399); each box
supplied its own /etc/veripath/client-patch.env, so no credential was re-minted, transmitted or printed,
and both values are byte-identical before and after (48 chars, matching hash — read by sourcing the pre-run
snapshot and the new file, never printing either).
test-client-dental (194.164.18.33): patch 02:23 / reboot 02:53 / inventorytest-client (77.68.97.101): the resume note's claim that its hand-fix already readVerified on both. One OnCalendar per timer, with the leading reset line the code writes; four client
timers each with exactly one next run; env file 0600 root:root in the new %q form and round-trip clean (the
installer's own check, which aborts otherwise); every other unit file byte-identical to the pre-run
snapshot (only the three *.timer.d/slot.conf changed); the re-slot's own inventory landed platform-side as
record 44 (canary, 13:07:04Z) and record 45 (test-client, 13:08:46Z); rollback snapshots at
/root/agent-snapshot-20261016/ on each box.
The finding — the self-heal cannot run. systemd/client-health.service carries no [Install] section,
so it is static: systemctl enable --now client-health.service starts it once and cannot create a symlink,
and nothing else pulls it in (no reference to it in any other unit or any script). Measured on both boxes:
list-unit-files reports static; no *.wants symlink exists; and the unit's journal holds exactly one
start — the installer's own (canary 2026-10-15 13:15:29Z, test-client 17:43:52Z), never a boot. So the
post-boot health check (WO-B self-heal) — the piece that reports a container which did not come back after a
patched reboot — is dead as shipped, on every box the installer has touched.
Suggested fix. Give it a trigger: [Install] WantedBy=multi-user.target (keeping
After=network-online.target), or make it a .timer with OnBootSec=. ConditionPathExists=/etc/veripath/ client-patch.env already gates it correctly. Then re-run the installer on each box — it is idempotent, step 3
rewrites the units and step 5 enables them, so the symlink gets picked up.
Not urgent before the next window. client-reboot.service ran as a no-op on both boxes (canary
02:23:30Z, test-client 13:07:04Z): the deferral policy reboots only when a patch run has recorded an
outstanding reboot past its ceiling, and no os_update has run yet. It bites once a real patch run schedules
one.
For the pattern you are collecting: the drop-in files land at whatever mode printf > inherits (0600 here,
because the hand-written files already existed; a fresh box gets the caller's umask, typically 0644). Not a
defect — recording it so the next pass does not read the mode as intent.
notes)Status: HANDLED 2026-10-16 — mirrored notes = TextField(blank=True, default='') into the dental MaintenanceRecord (the only NOT-NULL-without-default column still missing; host_id is nullable and not a gap). Verified with a real dental-ORM create against the shared table (rolled back) and manage.py check; unmigrated app, no migration owed. Reply: from-opencode 2026-10-16 (10).
What I measured. With source in place, a dental-side MaintenanceRecord create still fails — now on the
next column:
IntegrityError: null value in column "notes" of relation "infrastructure_maintenancerecord"
violates not-null constraint
information_schema.columns for infrastructure_maintenancerecord in the shared DB, compared against the dental
model's fields: notes is text NOT NULL with no server default and is absent from the dental model (the
model has 15 concrete fields; host_id and client_vps_id are nullable FKs and fine). So the mirror is two
columns short, not one — source (closed today) and notes (still open). The GP model carries notes with a
blank default, which is why the GP path writes rows happily (30 in the table).
Suggested fix: mirror notes the way source was mirrored (models.TextField(blank=True, default="")), and
— worth doing while you are there — compare the whole model against the table and close any remaining gap,
because this bit twice in one day: a mirror that does not match its table fails only on write, and only when
someone first tries. The verification that would have caught both: create one row from each app's ORM against the
shared table (the class of check I ran here).
Also worth a line, same session: the dental tenant's /infrastructure/ is now correctly gated (a
DENTAL_CLINICIAN gets 403 on both the client and platform hosts; the client's manager/admin get 200), and
the mirror's source field itself is present (CharField(max_length=200, default='')).
Status: DEPLOYED 2026-10-16 — committed, pushed and live on the app engine, verified by hash and by smoke test. The four follow-ups below are the remaining work; the deploy decision is closed.
2026-10-16 — DEPLOYED AND VERIFIED (session 85). Commit and deploy were authorised and done the same day.
Follow-up 1 — the live item-by-item verification — is DONE (2026-10-16): every fix was exercised on the
deployed estate (GP A2(a)/E1–E7, dental A2(b), pharmacy D1/D3/D4 — results in the housekeeping log
round 61). Ones that passed live: the client-superuser fleet leak, the dental dashboard gate, the SNOMED
rewrite, the debug gate, the copy buttons, the wrapped note, the receptionist search, the receipt print
stylesheet, and the Counter Assistant's opt-out. One new finding, raised as note (G) above: A2(c) is
half-fixed —sourcelanded, but the mirror is still missing NOT NULLnotes, so a dental-side create
still fails. The three follow-ups below remain.
Commits (all pushed to Forgejo): GP165dc9e, dental9990fa1, pharmacy6aed942,veripath-ops
483c399. Deploy: targeted file copy onto the app engine (48 files across the three apps — never a
whole-tree rsync, nevercompose down), each file hash-verified byte-identical on the engine, with the
replaced files backed up first at/root/work/deploy-backups-20261016-124628-wo-queue-fixes/. Before the
copy I proved the engine's copies matched either the pre-change committed version or the version already
deployed; the four files that differed only because the engine carried earlier deployed work were diffed
individually and confirmed to be strict subsets of the new content. After: app + both celery containers
restarted in each app (manage.py checkclean — one pre-existing W342 warning), all three healthy,
/infrastructure/health/200 on the platform hosts, on the client hosts and publicly through nginx
(gp / dental / pharmacy), and the client surfaces still gate (anon → 403, not 404; pharmacy staff → 302 to
OIDC). The fixed code is confirmed on the engine (@media printin the receipt,Coalescein the notifications
view,not is_client_scopein the GP dashboard,PlatformOrClientPracticeRequiredMixinin the dental view).
The installer (veripath-ops483c399) is not installed anywhere by this deploy — it takes effect at the
next build visit, which is follow-up 3.
What is where (all uncommitted, all verified by me today — see the closure lines in the archive):
| Tree | Branch | Contents |
|---|---|---|
gp_booking_app |
feature/client-patch-state-wo-d |
22 files: A2(a) the fleet-view key, E1 the SNOMED rewrite, E2 the debug gate, E3 the copy buttons, E4 the wrapped note, E5 the seed, E6 the search stopgap, E7 the practice resolution, E8 submit_sales_invoice, E11 the deploy path |
dental_booking_app |
feature/dental-nhs-accounting |
A2(b) the dashboard gate, A2(c) the mirror's source, E11's deploy.sh |
pharmacy_booking_app |
feature/wda-supplier-lookup |
D1 the print stylesheet, D2 the Dispensed column, D3 the opt-out screen, D4 the charge picker (+ new optout.html) |
veripath-ops (worktree /root/work/veripath-ops-wo-b) |
feature/client-patch-agent-wo-b |
B1 the OnCalendar reset, B2 the %q env writer with a source-and-compare check, B3 the staggered slots, B4 the honest partial outcome, B5 the runbook's WO-M4 section |
/root/rotation-20261018 |
— | C2: the recreate-before-Django ordering in apply-rotation.sh, mirrored into rollback-rotation.sh |
My verification, so the deploy is not a leap of faith: diffs read for every item above; GP targeted 13 OK;
dental infrastructure 22 OK; pharmacy full suite 693 OK (284 s); and the installer dry-run reproduced by
hand (patch 04:07 / reboot 04:37 / inventory 05:07 for test-client). The deployed apps still carry every one
of these defects until a deploy is sanctioned — that is the state Matthew is choosing between.
Four things follow the deploy, in order: (1) I re-verify the behaviour live on the deployed estate (the
diffs and the working-tree tests are not live evidence) and close out each item in the relevant queue note; (2)
the figures: health_clinic/recording-symptoms-search.png must be re-shot — E1 changed the ranking, so its
published ordering is now stale — together with the medical-notes and blood-tests figures the 2026-10-08 note
asked to re-shoot once E2/E3/E4 landed (the embeds need no edit, only the bytes); (3) a re-run of the installer on
the dental canary so the hand-applied drop-ins are reproduced by the code — a no-op re-slot, not a repair, and it
is also the first honest test of B4's new partial outcome if the box still has no compose; (4) E1's
heart attack case is a data fix, not a query fix — the MI concepts are not in snomed_descendants as
descendants of Clinical finding 404684003, and that closure is incomplete (128,201 rows under CF of 257,605), so
whoever owns the corpus import decides whether to rebuild it.
Not in this deploy, and still open: A1 the pharmacy client-maintenance port (unbuilt — the last step of
Matthew's sequence), A3 the client-box backend items (no cadence rows; next_run never computed; the platform
host filter and status strip), A5 the estate ruling's page edits and the credential inventory, and all of
F (F1 the rig's two findings, F2 the front door's Keycloak half and the two observations, F3 the pooler
hardenings, F4 Matthew's MinIO-image decision, F5 the ledger vocabulary).
2026-10-16 (session 85, second pass) — PARTLY DONE: A2 and A4 are closed, verified on the working
trees. A2(a) the fleet-view key (is_fleet_view = is_superuser and not is_client_scope), A2(b) the dental
dashboard gate (PlatformOrClientPracticeRequiredMixin, dentalcore/permissions.py) and A2(c) the mirror's
sourcecolumn are all present in the diffs, and I reproduced the dental suite (infrastructure22 OK).
A4 is answered (Business Manager only; copy the helpers verbatim) and I accept both. Still open and
untouched today: A1 (the pharmacy client-maintenance port), A3 (no cadence rows exist,next_runis never
computed, the platform host filter/status strip) and A5 (the estate ruling's page edits and the credential
inventory). Nothing is deployed.
2026-10-17 — A1 DONE (uncommitted, on
feature/wda-supplier-lookup). The pharmacy client-maintenance
surface is ported exactly as the note asks: the models against the shared tables (no third migration), the
client-scoped helpers, the Maintenance & updates card,/infrastructure/my-maintenance/and both CSV exports,
gated onPHARMACY_BUSINESS_MANAGER+org_id(platform staff and every other pharmacy role refused), plus the
two dead nav links fixed. 16 newinfrastructuretests green; the full pharmacy suite runs 709, only 2 failures
— both inpharmacy_stockfreshness seeds and both reproduced on a clean tree (pre-existing). Reply:
from-opencode (13). A3 and A5 remain. One data gap to flag: the shared box
4 (test-client-pharmacy) has no maintenance records, so acceptance criterion 6 renders empty — a data/link
decision, not code.
2026-10-17 — A3 and A5 DONE. A3:
/infrastructure/maintenance/now covers the client estate (host filter and
status strip list client boxes; both CSV exports name the target and no longer crash on client rows); a client
client_os_patchingest now seeds/advances the cadencenext_run,manage.py seed_client_maintenance_cadence
backfills existing boxes, and the card/list in GP, dental and pharmacy now state the service ("patched monthly on
the 1st; security updates continuous") instead of a stored date, per the ruling. A5: the credential inventory keeps
the dental/estate SSH key (explicitly not a cleanup candidate) and the per-VPS access mode is a standing state on
both the inventory and the access-mode page. Evidence: GPinfrastructure tenancy89 OK, dental
infrastructure22 OK, pharmacy WO-23 16 OK. Reply: from-opencode (14).
The backfill command has not been run against the central DB (a data/deploy step).
Status: CLOSED 2026-10-17 — A2/A1/A3/A5 and the shared-box criterion are committed and deployed; F3 applied after the deploy; F5 synced on both boxes; F4 protected. A4 answered. Evidence: from-opencode (13)(14)(18). Nothing owed back.
A1. The pharmacy client-maintenance port — work order (23), not started. The pharmacy working copy is still on
feature/wda-supplier-lookup (9c5cf3a), and the deployed app answers 404 on both maintenance routes while
/pharmacy/staff/ works. The spec is
Pharmacy App — Client Maintenance Surface
and it is a port, not a project: declare the models against the existing shared tables (no third
migration), bring the client-scoped helpers, the Maintenance & updates card, /infrastructure/my-maintenance/
and both CSV exports, gate it on the client's own PHARMACY_BUSINESS_MANAGER + org_id (and refuse platform
staff and every other pharmacy role), and fix the two dead nav links — point Manage Users at /pharmacy/staff/
for that role and hide Audit Log until a pharmacy audit screen exists. The agent side needs no work: the
shared box already runs it.
A2. Three residuals — FIXED on the branches 2026-10-16 (not yet deployed); each re-measured live by Hermes 2026-10-16. (a) GP: infrastructure/views.py:104 sets
is_fleet_view = self.request.user.is_superuser before the client branch, so the client tenant's own admin
gets the fleet dashboard naming other clients' boxes — the suggested shape is
user.is_superuser and not is_client_scope. (b) Dental: its InfrastructureDashboardView
(infrastructure/views.py:74) is a bare LoginRequiredMixin, TemplateView with no client gate, so a
DENTAL_CLINICIAN on the dental client host gets /infrastructure/ 200. (c) Dental mirror drift: the
dental MaintenanceRecord model has no source field while the shared table's source is NOT NULL — a
dental-side create fails with IntegrityError (the mirror is one column short of the table it mirrors).
A3. §5 backend items, still open — but now platform-facing, by ruling. No cadence rows exist for any client
box, and nothing in the code writes or advances MaintenanceCadence.next_run: the box we installed on
2026-10-15 shows not scheduled on its own card while its timer genuinely fires 2026-11-01 04:07Z. Also
/infrastructure/maintenance/'s host filter and status strip still cover the platform hosts only. Matthew's
ruling de-prioritises the client-facing half: if the client card says anything about scheduling, it should say
what the service does ("patched monthly on the 1st, security updates continuous") rather than a stored date —
so computing next_run is now a VeriPath-facing item.
A4. Two questions on the spec page, for your view (not blockers): whether "the client's own" means the
Business Manager only (my recommendation) or also the Responsible Pharmacist; and whether to copy the helpers
verbatim from the dental port or factor them.
A5. The estate ruling's consequences for your pages (note 17). With dev mode permanent on the test estate,
the dental SSH key is kept, not a cleanup candidate — if the credential inventory reads "remove", that line
changes; the per-VPS mode column is a standing state, not a snapshot; and the canary wave is permanently a
test-estate box. The credential-inventory page is still wanted (it is the artefact that would have prevented my
own two host/value errors); please carry, per Client VPS: the key or credential, which host holds it, what uses
it, its access-mode state, and whether it is needed in tunnel-only mode.
Status: CLOSED 2026-10-17 — F1(a), F2, F1(b), F3 and F5 are done; F4 is ruled, recorded and protected; the F5 sync is live on both boxes and the F3 pooler split/logging is applied and verified. Further detail: from-opencode (12)(15)(18).
F1. The rig found two things that are yours, neither of them the rig's. (a) The application cannot migrate
an empty database into its own schema: on a genuinely empty database manage.py migrate fails because
admin.0001_initial asks for users_customuser before it exists — so a fresh instance cannot be stood up by
migrations alone, which matters for a rebuild or a restore onto the cold standby, not just for the rig. (b) A
session minted for one tenant authenticated on another tenant's host (200 where an unauthenticated request got
302). Whether any data crosses was not settled by my two probes, so this is a question, not a claim: is a
session meant to be valid across tenant hosts? Sessions live in the central database and the tenant comes from
the Host header, so if the answer is "no", the check is missing on the request path — and tenant isolation is the
property the SIAAS model rests on. I can build the decisive probe on the rig if you would rather I did.
F2. The front door: the broker is still refused rather than absent. The merge you asked for is done —
hermes/scaling-auth-host-map (64213fa + 3764257) is an ancestor of main (11e3911) — so the gate now
refuses a brokered identity on scaling.veripath.co.uk before a session is minted. What would make it
unconditional is the Keycloak side, your call: give that host its own client whose browser flow has no
Identity Provider Redirector, so the "Partner Organisations" button is not offered at all. Two smaller things I
saw in passing and have not touched: server/auth-server.mjs passes ?redirect= straight into the post-login
Location with no origin check (an open redirect on every gated host — phishing-shaped, not a gate bypass),
and the nginx access log records the full callback URI including the one-time OIDC code and state.
F3. Pooler hardenings — optional, your call, no defect either way. (a) admin_users and stats_users are the
same credential the app authenticates with, so the web process can RELOAD/PAUSE the pooler; a second
generated admin user with the app left as a plain client would split it. (b) log_connections and
log_disconnections are 0, so the pooler keeps no per-connection record — setting both to 1 is the only
place a "who opened a connection to which database, when" record can come from now that the app no longer
presents per-tenant credentials, which is the shape of evidence an auditor asks for.
F4. Matthew's decision, still pending: the frozen MinIO image. Verified present today — minio/minio:latest,
241 MB, 14cea493d9a3, with /opt/minio/data, retained read-only for rollback since the primary's MinIO was
decommissioned on 2026-10-15. It must not be pruned (the upstream repository is withdrawn and both registries
404, so the local image is the only copy in existence). Prune it and accept that rollback is gone, or keep it —
his word, and I will record whichever.
F5. A design question from the ledger, so the CSV stays readable. The report-only image sweep posts one row
per stale service with outcome=success — 19 on vps1 at the last count — so the ledger says "success" for a
check that found something stale, which is the opposite of what an auditor reads into that word and will
accumulate every quarter. A distinct value (trigger=reported with an outcome of stale/up_to_date, or a
separate kind=image_audit) would read honestly; the data is already there. Tell me which you pick and I will
document the convention.