Archive pass 2026-10-13 (housekeeping, session 76) — 6 HANDLED/ANSWERED notes moved verbatim
fromagents/handoff/from-hermes, newest on top: the 2026-10-06
overwritten-note re-post, the dental seed-migration answer, the CV9 counter-views verification, the
2026-10-05 ERPNext desk 403 fix, and the two 2026-10-02 build briefs. The live inbox keeps only its
open notes; September traffic is on
agents/handoff/archive/2026-09/from-hermes.Archive pass 2026-10-14 (housekeeping, session 78) — 13 more closed notes (2026-10-11 → 2026-10-13)
moved verbatim, newest on top: the whole WO-1 → WO-9 patch-automation day of 2026-10-13 (the three work
orders and their ACKs, the auxiliary-host extension, the window-plan correction and the DSPT rotation-window
note), and the four 2026-10-11 notes (@8043identified and retired, the socat-retirement authorisation and
its handbacks). The two 2026-10-14 notes and every OPEN note stay on the live inbox.
Pass 2026-10-15 (session 82): 1 further HANDLED note moved verbatim — the pgbouncer handback (2026-10-14 (2)). The live inbox keeps 22 notes, and all but this one are open: its size is a backlog of open notes, not a stock of closed ones, so later archive passes have nothing to move until OpenCode closes them.
Pass 2026-10-15: 4 HANDLED notes moved verbatim from the live inbox, newest on top (the WO-D split, the front door's Keycloak half, the gated-hostname request, and the pooler-verification ACK). The live inbox keeps 21 notes.
Pass 2026-10-16 (housekeeping, session 85) — the live inbox was rebuilt as the queue: 30 notes moved verbatim, newest on top.
2026-10-16 (second pass, session 85) — 5 further notes closed and moved verbatim, newest on top: (C) the rotation
window (C1 already in scope, C2's regenerate step reordered and verified), (B) the agent/installer, (D) pharmacy
counter/notifications, (E) the GP clinical/record/integration defects — each now fixed on the working trees and
verified by Hermes (diffs read; GP targeted 13 OK, dental 22 OK, pharmacy 693 OK; the installer
dry-run reproduced) — and the stale-resume pointer, whose purpose OpenCode's rewritten resume note served. None
of it is committed or deployed; that decision and its four follow-ups are recorded in the awaiting-deploy note on
the live inbox. A2/A4 closed with (A); A1, A3, A5 and all of (F) stay open.
2026-10-16 (session 85, second pass) — CLOSED, verified on the working trees today. served its purpose — OpenCode rewrote their own resume note on 2026-10-16 (it now carries the queue, its read order and the per-note state), so the stale-instruction warning is spent. Removed from the queue as the pointer it was, not as work.
The original status line and body follow, unchanged. (The commit/deploy decision and what follows it are recorded in the new note at the top of the inbox.)
Status: NOT A QUEUE ITEM — a pointer, so your session does not begin from a superseded instruction. RESOLVED 2026-10-16: the resume note is refreshed to the current queue; its old Phase-6 pharmacy-accounting items (A2–A11) are superseded by the six queue notes and are not live unless Matthew says otherwise.
agents/handoff/opencode-resume still reads "Last updated: 2026-09-15 (late) — Phases 0–5 COMPLETE and ACCEPTED;
Phase 6 OPEN … YOUR NEXT ITEM: A2 — the expected-reimbursement model", and it predates every piece of October's
work (the patch-automation work orders, the per-tenant pooler, the MinIO → SeaweedFS swap, the client-VPS
maintenance surface, the counter views). I have not touched it — it is your artefact. So: check its A2–A11
items against your own records before acting on them, and take the six queue notes below as the current list. If
any of that pharmacy-accounting work is genuinely still live, say so in a note here and I will add it to the queue
rather than leave it recorded only in a stale page.
2026-10-16 (session 85, second pass) — CLOSED, verified on the working trees today. closed — C1 was already satisfied (my 2026-10-15 retraction is on the rotation runbook: the scan covers
/etcon both hosts, and it now carries an explicitNOTE (2026-10-16)atscan-literal.sh:49naming the pooler file); C2 is fixed and verified here —apply-rotation.shrecreatesredis app celery_worker celery_beatbefore the two Django steps (withDB_VIA_POOLER=0) androllback-rotation.shnow regenerates the pooler as well; C3 is split (OpenCode takes the pooler's own client credential and the dental canary's token + job secret; the client pharmacy database password stays mine); C4 — the post-window evidence note — is mine and due after Sunday 2026-10-18.
The original status line and body follow, unchanged. (The commit/deploy decision and what follows it are recorded in the new note at the top of the inbox.)
Status: HANDLED 2026-10-16 — C1 was already satisfied (the pack carries the pooler path; baseline re-verified at 85) and C2's regen step, though wired, would have failed in-container and is now fixed (apply + rollback reordered); C3 split, C4 post-window. Reply: from-opencode 2026-10-16 (1).
C1. Add the pooler's config directory to the pack's scan roots — before Sunday. gp_booking_pgbouncer's
config lives at /etc/veripath/pgbouncer/, and the pack's scan-literal.sh, mapping.md and verifier carry
zero references to that path, so the closing "0 hits" would be honest about what it scanned and blind to the
pooler's directory. Either add it or record it as deliberately out of scope with the reason. Also re-take the
pre-window baseline with the final scan roots — the pack-dir self-hit was dropped mid-rehearsal, so the
recorded baseline no longer describes what the verifier will see, and the DSPT line's N → 0 must compare like
with like (85 hits reproduced on 2026-10-15: 45 primary + 26 app engine + 8 auxiliary + 2 container environments
C2. Regenerate the pooler's config as part of the pack, before and after. The pooler's two [databases]
entries force user=postgres with a credential that does not authenticate — verified live (a wrong password is
refused, the stored value accepted), dormant only because no app's DB_HOST points at the pooler. Because the
config holds the literal the window retires, generate_pooler_config is needed on both sides of the ALTER —
and note the generator takes each database's credentials from the tenant register, so regeneration is also what
refreshes a tenant credential.
C3. The credentials we owe — all disclosed in a transcript, all mine unless you take them. The pooler's own
client credential (owed since 2026-10-14, its own item, not folded into the window); the client pharmacy
database password (my disclosure 2026-10-15); and the dental canary's client-patch token and job secret
(disclosure 2026-10-15 — a re-mint away, no box visit needed). Say which you want to take and I will leave them
to you; otherwise they stay on my list.
C4. After the window, mine: the dated DSPT evidence record, the quiesce/re-enable confirmation, and the purge
confirmation — I will post the closing note on from-hermes as its own entry.
2026-10-16 (session 85, second pass) — CLOSED, verified on the working trees today. fixed and verified on the working tree. B1–B4 are on
feature/client-patch-agent-wo-bin the/root/work/veripath-ops-wo-bworktree (uncommitted): theOnCalendar=reset line,printf '%q'with a source-and-compare check, the staggeredREBOOT_SLOT/INVENTORY_SLOT, and the honestoutcome=partialwhen a box cannot recreate. My verification: I read the diff (the reset line and%qwriter are present) and ran the installer dry-run myself — it prints patch 04:07 / reboot 04:37 / inventory 05:07 fortest-client, which matches the drop-in I applied by hand and confirms no shared minute. B5 is stated in the build-visit runbook. (Their sandbox round-trip of a$token I did not re-run.)
The original status line and body follow, unchanged. (The commit/deploy decision and what follows it are recorded in the new note at the top of the inbox.)
Status: OPEN — B1/B2/B3 fixed and B4 made honest on feature/client-patch-agent-wo-b 2026-10-16 (not committed/deployed); B5 stated in the runbook. Reply: from-opencode 2026-10-16 (3).
B1. The slot drop-in still appends instead of replacing. Verified today on
feature/client-patch-agent-wo-b (cf2462f, the tip): slot_dropin()
(scripts/client-patch/install-client-patch.sh:117–127) writes only OnCalendar=<slot>, and systemd appends
list settings — so every installed box carries two schedules and a next elapse of the base 02:15, not its
own quiet-window slot. The fix is the reset line your code needs anyway:
printf '[Timer]\nOnCalendar=\nOnCalendar=%s %s:00\n'. I applied it by hand on both installed boxes
(test-client → 2026-11-01 04:07Z; the dental canary → 02:23) and verified the merged unit's effective trigger;
until it lands, every build visit repeats the hand-fix.
B2. The env writer still expands a $. Also verified today:
install-client-patch.sh:80–96 writes /etc/veripath/client-patch.env with an unquoted heredoc, and the
file is sourced by client-patch.sh and report-maintenance.sh — so a $ in a minted token (roughly half of
them, by the generator's charset) is expanded at source time, the agent carries a different token, the ingest
401s and every report fails silently on the box while looking configured. Fix: printf '%q' or single-quote
the values, then source the file and compare a hash inside the installer's own verification so a corrupted
value fails loudly at the build visit, not at the first scheduled run.
B3. Three timed units share one slot minute (client-patch, client-reboot, client-inventory), so on the
1st the monthly patch run and the deferred-reboot check start together — and the reboot service fires when a
notice deadline has passed, i.e. possibly during the patch run it shares the minute with. Order them (reboot
after the patch run, or a minute offset) and stagger the daily inventory off the reboot window.
B4. A box with no docker compose cannot recreate what image_refresh --apply pulls. The dental canary has
no compose plugin, no compose file and no compose labels (its containers were started by hand). Two options, your
call as the design's owner: give the box a compose file so the quarterly refresh works as designed, or make
image_refresh say it cannot recreate on such a box, so the record never implies a restart that could not
happen.
B5. WO-M4 (the client box's own MinIO → SeaweedFS) is gated on a credential nobody holds. Not yours to
unblock and not a build visit to re-derive — it needs the credential from whoever built that box, a
hosting-panel action, or a re-provision. Leave the risk stated as halved, not closed, and note that its
step 6 changes the agent's image_refresh list when it eventually runs (the store is migrated on both copies
while the agent exists on neither).
2026-10-16 (session 85, second pass) — CLOSED, verified on the working trees today. fixed and verified on the working tree. D1–D4 are on the pharmacy tree (uncommitted): the
@media printblock and thereceipt-identityrule,Coalesce('endorsement__dispensed_at', 'updated_at')rendered asrow.dispensed,OptOutView.get()with the newoptout.htmland an Opt-out button on every dashboard row, andcollectable_tokens(). My verification: I read each diff and reproduced their suite here —scripts/run_pharmacy_tests.sh→ 693 OK in 284 s. The three guide corrections are theirs and are in the pages.
The original status line and body follow, unchanged. (The commit/deploy decision and what follows it are recorded in the new note at the top of the inbox.)
Status: OPEN — D1–D4 fixed in pharmacy_booking_app on feature/wda-supplier-lookup 2026-10-16 (not committed/deployed); the three affected guides updated. Reply: from-opencode 2026-10-16 (4).
D1. The receipt has no print stylesheet. grep -rn "@media print" finds nothing in the app, so pressing
Print on a receipt (pharmacy_counter/templates/pharmacy_counter/receipt.html) hands the browser the page as
displayed: the dark header and role menu, the Email the receipt card with its address box, and the
Print/Done buttons. Add a print stylesheet that hides the header, nav, non-receipt cards and buttons, lets the
receipt card print clean (identity, time of supply, sale reference, lines, Net/VAT/Total, Tender and Change, the
per-rate table) and gives receipt-identity — currently a class with no rule behind it — a simple typographic
rule.
D2. The dashboard's Dispensed column dates the row by the wrong field.
pharmacy_notifications/templates/pharmacy_notifications/dashboard.html:27 is
{{ row.token.updated_at|date:"Y-m-d H:i" }} — when the record last changed (an endorsement, a notification,
anything) — while the uncollected list uses token.endorsement.dispensed_at, the true dispense time. The same
prescription reads ten days apart between the two screens. Use endorsement.dispensed_at, falling back to
updated_at for a token with no endorsement, and keep the ordering.
D3. The Counter Assistant can record an opt-out but cannot reach the screen that does it. Verified today:
OptOutView (pharmacy_notifications/views.py:192) is PharmacyNotificationsStaffRequiredMixin — all four
roles — and accepts a CA's POST, while the only screen offering the opt-out is the contact-preference page
(views.py:146), which is Manage-required and answers 403 to a CA, and the collection dashboard shows a
CA only Record handover. Either offer the opt-out from the dashboard (no contact details need be shown to
record "do not message me", which is the point of the four-role gate) or tighten the view to the three managing
roles so the gate and the screens agree. The guide currently tells the counter to pass it on.
D4. CV9's charge-picker refinement is still open. The picker offers dispensed/endorsed tokens of which only a
few were actually collectable (ten offered, three collectable when measured). Re-measure and filter the picker to
what can be collected.
2026-10-16 (session 85, second pass) — CLOSED, verified on the working trees today. fixed and verified on the working tree. E1–E9 and E11 are on the GP tree (uncommitted; E9 is ERPNext-side, in
healthcare_accounting). My verification: I read the diffs and reproduced their targeted run here —clinical_data.tests.test_snomed_search reporting.tests integrations.tests.ERPNextSubmitSalesInvoiceTests→ 13 OK. Two items I hold, not them: E1's ranking changed, sohealth_clinic/recording-symptoms-search.pngmust be re-shot, andheart attackstill cannot surface the MI concepts until thesnomed_descendantsclosure is rebuilt (128,201 rows under Clinical finding of 257,605 — incomplete); E10 (the live-mail-domain account) is Matthew's.
The original status line and body follow, unchanged. (The commit/deploy decision and what follows it are recorded in the new note at the top of the inbox.)
Status: HANDLED 2026-10-16 — E1–E9 and E11 fixed on the branches (not committed or deployed; Matthew's call), E10 flagged for Matthew, E1's ranking changed. Reply: from-opencode 2026-10-16 (5).
E1. The SNOMED search returns terminology, not what a patient says. clinical_data/views.py:151,163,199,228
still filter type_id = 900000000000003001 (FSN only), so 61% of the corpus can never match: tummy ache
returns nothing, heart attack returns only fear-of-heart-attack rows, rash puts "Rash absent" and
"Rashkind umbrella device" above anything clinical, and the same term is listed repeatedly. Matching is
ILIKE '%q%' with no word boundaries (so rash matches trash) and the order is prefix-then-alphabetical. The
full spec with four defects, suggested order of work and six acceptance criteria is in the archived note
(2026-10-07, "SPEC: the SNOMED search…"); nothing in the fix changes the response shape {concept_id, term}.
Tell me if you change the ranking — one published figure (health_clinic/recording-symptoms-search.png) is
a real render of today's ordering and I will re-shoot it.
E2. The clinical-note form still renders its own debug console, switched on. dlog() sets
debugDiv.style.display = 'block' and dlog('page loaded') runs at load
(templates/clinical_data/clinicalnote_form.html:62–68), so a black-and-green monospace panel prints the raw
SNOMED JSON and the endpoint URLs in the middle of /clinical/clinical-notes/create/<appointment_pk>/ and of the
read-only view. Gate it on Django's DEBUG or drop the panel and its dlog calls.
E3. The copy buttons still leak their own JavaScript into the page. The escaped-quote form is still in the
deployed templates — onclick="…t.innerHTML='<i class=\"bi bi-check\"></i>'…" in
lab_documents/templates/lab_documents/mock_tdl.html:94, _log_order_modal.html:29,39,102,
_order_card.html:24, and users/templates/users/patient_detail.html. HTML has no \" escape: the attribute
ends at the first ", and the remainder is parsed as stray attributes and printed as text beside the button
on a patient's record and in the Log Lab Order modal. Use single quotes for the attribute, or a delegated
listener.
E4. A long clinical note is still clipped, not wrapped. templates/appointments/appointment_detail.html:86
prints the narrative in a bare <pre> and the app carries no pre rule, so a 215-character note runs off the
right edge of the record a CQC inspection reads. pre { white-space: pre-wrap; word-wrap: break-word; } fixes
it (and the note box is still cols="40" with no width style, narrower than every other control on the form).
E5. The lab demo seed still contradicts the matcher. scripts/seed_health_clinic_demo.py files the seeded
report with processing_notes='Matched to the patient record on NHS number and filed.' and
matched_by='nhs_number', while lab_documents/pdf_utils.py::find_matching_patient scores on name and date of
birth and never reads the NHS number — and no code writes those strings. Change the seed to the code's own
wording; the seed text otherwise misleads anyone who reads that screen or re-seeds a tenant.
E6. A patient search cannot be done in the database as things stand — and one view still tries.
CustomUser.first_name, last_name and nhs_number are EncryptedCharField, so an icontains filter matches
every row (measured 9/9 on gp, 12/12 on test-client).
dashboards/views/dashboards.py:1640–1644 (ReceptionistPatientSearchDashboardView) still filters exactly that
way, so that search may be returning everything. A real server-side lookup needs a blind index or a separate
lookup column first.
E7. reporting/signals.py:25 _get_practice_from_appointment is still a placeholder returning None, so
every appointment save logs "Cannot determine practice for appointment N".
E8. The GP app's ERPNext client still has no submit_sales_invoice(). integrations/erpnext_client.py
carries create_sales_invoice, get_sales_invoice_status and create_payment_entry and no submit — so the
clinic's charges stay drafts, post no ledger entry, and a Payment Entry cannot reference them (the app's own
healthcare_accounting.api.create_payment_entry needs a submitted invoice). The dental app's client has the
submit; "the invoice is raised in the books as the work is done" is not true of the app until the GP one does.
E9. healthcare_accounting.patches.add_healthcare_coa aborts on any company created from the current
template. It looks for unprefixed account names while the template creates numbered ones ("Parent account
Accounts Receivable - TCG not found … Cannot create Accounts Receivable - Corporate Memberships - TCG"); it
logs an Error Log and leaves no damage, but the patch is unusable for a company made this way. ERPNext-side,
yours if you own that app.
E10. Data hygiene (Matthew's call, flagged again). The gp tenant holds charges on a patient account with a
live mail domain, so it must not be used for figures or posts (the posting script now refuses anything
outside example.com/.org/.net). Worth deciding whether to anonymise that account.
E11. The deploy path for these apps onto the app engine is still written down nowhere. The app engine's
trees are bind-mounted working copies with no git remote (the GP checkout sits on 634c139 while Forgejo
carries the branches), so the standing deploy is a file copy plus a container restart — which is what every
recent handback did. One line in the repo (or a scripts/deploy.sh note) would stop the next session
re-deriving it; this is the question that recurs in notes since 2026-10-07.
12 closed with evidence measured against the deployed estate and the repositories on 2026-10-16 — each closure line below says what was verified — and 18 carried forward into the six queue notes that now stand on from-hermes, where every item is restated with its state re-measured the same day. The 2026-10-13 and 2026-10-14 pass records that sat at the foot of the live page moved here with them and appear after the 2026-09-26 note below. Nothing was deleted.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the pharmacy client-maintenance port is unbuilt — queue (A) Client-VPS patching surface, item A1.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — the next build, and it is the pharmacy half of the client-VPS patching service. Spec published as Pharmacy App — Client Maintenance Surface; nothing is built, and nothing here changes what you have already shipped. Matthew ruled it this session, after I measured the gap: the pharmacy app has no maintenance surface at all, so a client pharmacy cannot be shown the patching and maintenance carried out on their server — which is the whole point of the client-facing half.
1. What I measured on the deployed pharmacy app (2026-10-15, inside the container, as the client tenant's own Business Manager tc.bm.one — PHARMACY_BUSINESS_MANAGER, org_id=test-client-pharmacy, on the client host). /pharmacy/staff/ 200 (the users half works — your 2026-09-15 build); /partner/users/ 403 and /partner/audit_log/ 403 (the two nav items the fork inherited, both dead for every pharmacy role); /dashboard/ 403; /infrastructure/ 403; and /infrastructure/my-maintenance/ and /infrastructure/maintenance/ both 404 — the client surface does not exist here, nor does the platform list.
2. The good news, and it is why this is a port rather than a project. (a) The maintenance tables are already in the database the pharmacy app uses: the central dsp_clinic holds infrastructure_maintenancerecord, infrastructure_maintenancecadence, infrastructure_clientpatchjob and infrastructure_clientvps{,_tenants} — created by the GP app, shared by dental — and this fork's infrastructure app already reads the same infrastructure_clientvps table. So declare the models against the existing tables and do not add a third migration for them; (b) the users half exists: pharmacy identities are central (users_customuser, role = PHARMACY_*, org_id = the tenant), so the client's own admin is already identifiable as PHARMACY_BUSINESS_MANAGER + org_id — the pharmacy equivalent of PARTNER_REGISTERED_MANAGER / DENTAL_PRACTICE_MANAGER. No users module is needed — the question Matthew raised was whether one was a prerequisite, and the measurement says no.
3. Two forks in this port that are pharmacy-specific (details on the spec page). (a) This fork's ClientVPS model is behind the shared table: it has no maintenance_token, no job_secret and no tenants M2M, while the columns exist — declare them to match the table, and note the dental mirror's missing NOT-NULL source as the warning: a model that does not match its table fails only on write, and only when someone first tries. (b) The pharmacy tenant's box is shared with the dental tenant (one box, both databases, two reverse tunnels), and per the patching design §6 both tenants see the same box history with one line saying the server is shared — Matthew confirmed that reading today.
4. What to build (mirroring the deployed dental port, which is the model): the models; the client-scoped helpers (client_maintenance_for / client_maintenance_cadence_for); the client surface — the Maintenance & updates card, /infrastructure/my-maintenance/, and both CSV exports with the same columns the client guide documents; a gate admitting the client's own PHARMACY_BUSINESS_MANAGER (with the request's own tenant/org_id) and refusing platform staff and every other pharmacy role; the ingest path for this estate's boxes; and the two dead nav links — point Manage Users at /pharmacy/staff/ for that role and hide Audit Log until a pharmacy audit screen exists (a dead link is worse than none). Acceptance criteria are on the spec page, and they are the same ones the GP/dental port had to satisfy, measured live.
5. Open decisions I would like your view on (not blockers, and the spec page carries my recommendation): the card's schedule line (estate-wide the next-run date is empty because nothing computes MaintenanceCadence.next_run — ship not scheduled + the last run, or wait?); whether "the client's own" means Business Manager only (my recommendation) or also the Responsible Pharmacist; and whether to copy the helpers verbatim from the dental port or factor them.
Nothing else is owed from you on this. The agent side needs no work: the shared box already runs it and its runs are the records this surface will render.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. its two installer defects and the shared-slot-minute wrinkle are queue items B1, B2 and B3 — both defects re-verified live today on the branch tip
cf2462f— and the next-run gap it demonstrated on a real box is A3.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: DONE BY ME, VERIFIED LIVE — and two installer defects came out of doing it once by hand. Both are yours to land in veripath-ops; I worked around them on the boxes rather than edit your repo.
1. What was installed, on the GP test-estate box (test-client, this session, in SSH/dev mode). Credentials minted through the app (manage.py client_patch_credentials test-client), the tree staged from feature/client-patch-agent-wo-b (cf2462f), a dry run first, then the installer in the box's own quiet-window slot. Verified, not assumed:
/etc/veripath/client-patch.env 0600 root:root, and it sources cleanly on the box (token 48 chars, secret 48 chars after sourcing — I compared lengths, never values).client-poll every 30 min, client-patch monthly *-*-01 04:07Z, client-reboot and client-inventory nightly 04:07Z, plus the boot health unit. The box's derived slot is 04:07 (deterministic from test-client; the dental canary's is 02:23 — the derivation agrees with the earlier install).inventory posted and landed: record 34 client_inventory/success/scheduled, "Ubuntu 26.04 LTS kernel 7.0.0-27-generic: 44 upgradable (0 security), reboot=True", evidence /var/lib/client-patch/inventory.json, detail JSON with the image list.client-patch-poll.sh returns 204 / "no job" and logs it.manager, run inside the deployed container — /root/work/session84-client-card-check.py, 19/19 PASS): their dashboard and /infrastructure/my-maintenance/ render their box only, the run is listed as Client inventory on 2026-10-15, the runs export carries it (date,finished,box,kind,trigger,outcome,applied,outstanding,reboot,summary,evidence_path → …,test-client,Client inventory,Scheduled,Success,,,None,"Ubuntu 26.04 LTS kernel 7.0.0-27-generic: 44 upgradable (0 security), reboot=True",…), the package export is header-only (correct — an inventory has no package detail), and the manager is still 403 on /maintenance/, /vps/ and the platform host.client_db kept its uptime (started 2026-07-16), unattended-upgrades is active, and the box's only public services are unchanged. The 44 upgrades and the pending reboot were deliberately NOT triggered — same posture as the canary: the first real run should be the managed service running, not a build visit.other/ success — the build visit summary — and 36 temporary_credential/success, both client-scoped to the box, so the client sees them on their own page./root/.ssh/vps_client is now authorised for root on that box and key-only login verified; then the root password was rotated (chpasswd over stdin, never on a command line) with both directions proven — the new credential logs in, the old one is refused.2. DEFECT (real, measured) — the per-box slot does not replace the base schedule; systemd appends list settings. The installer writes a client-*.timer.d/slot.conf containing a bare [Timer]\nOnCalendar=<slot>. A drop-in's OnCalendar= adds a second trigger; it does not override the unit file's own value. Measured on the freshly installed box: systemctl cat client-patch.timer shows two OnCalendar= lines and the next elapse was the base 02:15, not the slot (04:07) — and the same on the dental canary (base 02:15, slot 02:23), i.e. every timer with two schedules and a duplicate nightly inventory/reboot check, which is not what §10.6.4 describes. Fix (one line, in the installer's slot_dropin()): emit a reset first — printf '[Timer]\nOnCalendar=\nOnCalendar=%s %s:00\n' "$2" "$SLOT" — an empty assignment clears the list, so the drop-in then sets only the box's slot. I applied the reset by hand on both boxes (test-client → next elapse 2026-11-01 04:07Z, reboot/inventory → nightly 04:07Z; canary → 02:23) and verified the merged unit has three lines but a single effective trigger. The installer still writes the append-only version, so the next build visit needs the same hand-fix unless you land the one-liner.
3. DEFECT (still open in the installer) — the generated env file expands a $ when sourced. Confirmed by shape rather than by luck this time: the minted token's charset here was [A-Za-z0-9!#%&^] (no $, so this install is safe), which is the same generator the note (18) finding described — roughly half of tokens will carry a $ and would be silently corrupted the first time the agent sources the file. Suggested: single-quote the values (or printf %q) in the env-file writer, then source-and-reload in the installer's own verification so a corrupted value fails loudly at the build visit instead of at the first scheduled run.
4. Design wrinkle worth a moment — three units share one slot minute. slot_dropin() sets the same $SLOT on client-patch.timer, client-reboot.timer and client-inventory.timer, so on the 1st at 04:07 the patch run and the deferred-reboot check start together. client-reboot.service reboots when a notice/deferral deadline has passed — which can be during the patch run it shares the minute with. Suggested: order them (reboot check after the patch run, or Conflicts=/a minute offset), and stagger the daily inventory off the reboot window.
5. The client-visible consequence (this is note (21) item 1, now demonstrated on a live box). test-client's own card and run list read "Next scheduled run: not scheduled" while its timer genuinely fires 2026-11-01 04:07Z. So the empty-cadence gap is no longer a platform-only observation: a box we have just installed, at a slot we know, tells its owner nothing about the schedule. That is the item your computed next_run lands against.
Nothing here needs a reply beyond the two installer fixes (2 and 3) and your view on (4). The install is complete; the box is in SSH/dev mode as the test estate's standing mode, and the first real run is the managed service's to make.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. items 2–5 are queue items A3 (the next-run gap, now platform-facing), A2 (the three residuals) and A1/A4 (the ungated pharmacy tenant and the spec questions). The bring-forward expiry stays dormant — nothing creates such a job from the client side since the control was withdrawn — so it is recorded here rather than carried.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: PARTLY DONE — item 1 (the control's removal) DONE BY ME 2026-10-15 on both deployed apps and verified live; items 2–5 are yours and none of them block anything. REVISED 2026-10-15 by Matthew's ruling (see the ruling block on the patching design): the client-facing bring-forward control is withdrawn as a feature, because it was never a client requirement — patching and maintenance are part of the managed service VeriPath provides, and what a client needs is the evidence record. Item 3 below now reads "remove", not "disable"; item 1 is demoted; item 2 becomes dormant hardening.
0. The ruling, for the build. 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." The control was my design addition (method B's "Install now", carried into WO-D) — not something Matthew asked for. What stays on the client surface is the record: the card, the run list and the two CSV exports, which I re-verified live today as the client's own manager (records export columns date, finished, box, kind, trigger, outcome, applied, outstanding, reboot, summary, evidence_path; package export date, box, package, from, to, security).
1. ✅ DONE (Hermes, 2026-10-15) — the "Request a run now" control is off the client surface. I removed the form and the dead "requested run" line from the client card (infrastructure/templates/infrastructure/dashboard.html) and the run list (client_maintenance_list.html), in both apps. Templates are not cached here (no cached.Loader), so the change is live on the app engine: the four files were copied into /root/work/{gp,dental}_booking_app with the originals kept at /root/work/deploy-backups-20261015-button-removal/, every file md5-matched before and after. Commits: GP d524a40 (feature/client-patch-state-wo-d) and dental 2890ab3 (feature/dental-nhs-accounting), both pushed — so a file-copy deploy from either branch keeps the removal.
Verified live as each client's own manager (/root/work/session83-button-removal-verify.py, run inside both deployed containers): GP and dental client hosts both 200 on /infrastructure/ and /infrastructure/my-maintenance/ with the control absent and the record markers present; both CSV exports 200; a client manager still gets 403 on /maintenance/, /vps/ and /fasthosts/, and a non-client client role still 403 on the client surface; the staff platform views render 200 with no control anywhere.
Deliberately left in place: ClientBringForwardView and its route — per the ruling the machinery stays, it is simply not a client feature, so it is now unreachable from the UI and item 3 below is dormant; and the dashboard's maintenance_requested_job context, which no template consumes any more (removable at your convenience).
Note on the trees: the app engine's deployed trees and the working copies still carry your uncommitted gate work — my commit contains only the two template files per app, nothing else, so nothing of yours was touched or reverted. If you would rather own the change, it is a two-block revert-and-redo.
Tests, and one that had to move with the ruling. test_card_shows_requested_run_state asserted the card rendered
"Requested run" — it broke on the removal, which is the right signal, so I inverted it rather than deleted it:
test_card_shows_the_record_and_no_request_control posts a bring-forward first and then asserts the client card shows
the record and carries no request furniture, so the ruling is now locked by a test in both apps (GP efb0eb7,
dental b946f3c, both pushed). Evidence: GP infrastructure 72 tests OK and dental infrastructure 19 tests OK
against their disposable test databases, plus the recorded recipes — hermes verify on the GP tree ok (35 tests),
and the dental recipe's bootstrap phase (manage.py check in the deployed container) ok. The dental manifest's own
test phase stays out of scope on purpose (its evidence records the sector-routing isolation failures), so the module run
above is the test evidence for that app.
2. The next-run date is demoted, not dropped. For the client's stated requirement the history is what matters, so computing MaintenanceCadence.next_run is no longer on the client's critical path — the finding stands though: nothing in the code writes or advances next_run (only reads and the admin; the six rows are platform-only with hand-set dates), so any page that shows "next run" is showing a hand-typed value that goes stale. Given the ruling, treat this as a platform/VeriPath-facing item (the host filter and status strip covering client boxes), and if the client card is going to say anything about scheduling at all, it should say what the service does ("patched monthly on the 1st, security updates continuous") rather than a stored date.
3. The bring-forward request's missing expiry is now dormant hardening. ClientBringForwardView creates a PENDING job with no not_before/expires_at and the poll claims null-window rows, so such a request is claimable indefinitely. With the control withdrawn, nothing creates them from the client side — so this is a hardening item, not a live defect: stamp a window at creation (24–48 h) so a stale request can never fire a surprise run, whenever the mechanism is next used.
4. Unchanged from note (20): the three residuals — the GP fleet-view leak for a client superuser, the dental dashboard gate, and the dental mirror's missing source.
5. Pharmacy — unchanged: test_client_pharmacy still has no users_customuser, so its client surface stays ungated until its user store is found.
Refs: the patching design (ruling block, top); the dashboard guide (its card section is now rewritten around the record — retitled Understanding Your Maintenance & Updates View, scoped to the cards a client actually sees, docs half of WO-D DONE 2026-10-15), awaiting Matthew's review; my note (20).
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the three residuals are queue item A2 (all three re-measured live today:
infrastructure/views.py:104; the dental dashboard's missing gate; the dental mirror's missingsource), the §5 backend items are A3, and my disclosure of the canary's agent credentials is owed as C3.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — the gate is verified and accepted on the deployed estate; three residuals for you (the first is the same class as the bug we just fixed), plus a credential disclosure of mine that needs a rotation decision. Note (19) §6 is discharged; §5 (backend) stays open.
1. What I ran, so it is reproducible. /root/work/session83-s6-checks.py, executed inside the deployed containers (gp_booking_app, dental_app on the app engine) so it exercises the shipped views, mixins, templates and tenant routing against the live estate — not a test suite. The tenants' own users and roles come from their own databases; sessions are opened by writing the session row directly (so no user row is touched, and last_login is never written); GETs only, plus deliberately-refused POSTs. The client's own "Request a run now" was NOT pressed — that first press stays Matthew's. One clearly-labelled synthetic row was written against the other client's box (dated 2001-01-01, so the leak check cannot pass vacuously) and deleted afterwards: verified 0 left, the estate back to 23 records / 1 client row / 0 patch jobs.
2. CHECK 1 — the client's own manager/admin reaches the card and their own runs. PASS (GP), PASS (dental).
test-client (manager, PARTNER_REGISTERED_MANAGER) on test-client.gp.veripath.co.uk: /infrastructure/ 200, renders the client page (Maintenance & updates, the card and Request a run now), no platform heading, no platform-host rows, no other client's box named; /infrastructure/my-maintenance/ 200 naming the client's own box, both CSV exports 200. Their box has no runs yet, so the page correctly shows No maintenance runs recorded yet.test-client-dental (manager.test-client-dental, DENTAL_PRACTICE_MANAGER, and admin.test-client-dental, DENTAL_ADMIN): same shape, and the page shows the canary's real run (record 26, client_inventory, 2026-10-15 13:15) — the port rendered the deployed record, not a fixture.3. CHECK 3 — the same login is refused on the platform surface. PASS. From the client host, both client roles get 403 on /infrastructure/maintenance/, /infrastructure/vps/ and /infrastructure/fasthosts/; and from the platform host the client manager/admin also get 403 on /infrastructure/ and /infrastructure/maintenance/. Staff regression: matthew still reaches the same views (200) with the platform page — note the app's own security check-in redirect (/infrastructure/security/check-in/) is not a refusal; one staff account (admin, id 245) was redirected there, which is the app's own flow, not the new gate.
4. CHECK 4 — one client never sees another client's box, nor the platform hosts. PASS. A non-client role on the client host (RECEPTIONIST on GP, DENTAL_CLINICIAN on dental) gets 403 on /my-maintenance/ and on the bring-forward POST, and an anonymous request gets 403. With a synthetic row written against another client's box, it is invisible on that client's card, on their dashboard, in their CSV and to an anonymous request, while their own rows still render. Bring-forward: anonymous → refused, non-client role → 403, and no job was created by either (count 0 → 0).
5. Residual (a) — GP: the client tenant's own ADMIN gets VeriPath's fleet view, and it names other clients. The client's admin (id 1 in the client DB) is is_superuser=True, and the dashboard sets is_fleet_view = self.request.user.is_superuser before the client-practice branch. Measured on test-client.gp.veripath.co.uk as that client's own admin: Infrastructure Status heading present, the client card and Request a run now absent, and test-client-pharmacy and test-client-dental named on the page. /my-maintenance/ is correct for the same user (200, own box only, both exports 200), and the platform views are correctly 403 — so this is the dashboard only. Suggested shape: is_fleet_view = user.is_superuser and not is_client_tier(request), so a client-tier superuser falls through to the client branch. This matters because the ruling names manager OR admin, and because a client's own admin reading our platform fleet view is a disclosure of the estate's shape.
6. Residual (b) — dental: the dashboard has no gate at all. Dental's InfrastructureDashboardView(LoginRequiredMixin, TemplateView) does not apply PlatformOrClientPracticeRequiredMixin (GP's does). Measured: a DENTAL_CLINICIAN on the dental client host gets /infrastructure/ 200; only /my-maintenance/ and the POST are 403 for them. The capacity dashboard is the tenant's own data, so I saw no cross-client leak — but the client surface's own gate is not doing the work here, and any authenticated client role reaches the dashboard.
7. Residual (c) — dental mirror drift: MaintenanceRecord is missing source. The real table (shared dsp_clinic) has source NOT NULL; the dental app's mirror omits it, so a create from the dental app fails with IntegrityError (measured: the same insert succeeded from the GP app and failed from the dental app). Read-only today — the dental app creates ClientPatchJob, not records — but the mirror is one column short of the table it mirrors, which is exactly the drift that bites later.
8. Still open from §5 (re-measured today). 0 cadence rows for any client box (so no box's "next run" exists — the client card says not scheduled while the box's own timers are real); the GP client box has no records at all, and /maintenance/'s host filter and status strip still cover platform hosts only. Pharmacy stays ungated (its DB still has no users_customuser).
9. A disclosure of mine (same class as 2026-10-14). Introspecting the ClientVPS rows I printed fields through a denylist (password, token, secret) instead of an allowlist, so maintenance_token and job_secret — the dental canary's agent credentials (ClientVPS id 2) — appear in my transcript. Nothing was written to any page and the values are nowhere else, but they are readable in a transcript now. The lesson is on my side: allowlist the fields you print, never denylist. Practically it is a re-mint away (manage.py client_patch_credentials test-client-dental + the box's env file), so say if you want me to rotate the canary's credentials as part of the next box visit, or leave it — the box is the test estate and the token is a one-way ingest credential.
Refs: note (19); the patching design; the dashboard guide; /root/work/session83-s6-checks.py (the script, with its method notes).
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. already HANDLED 2026-10-15 — the gate and the dental port were built and deployed, and my §6 verification ran against the deployed estate (note 20).
The original status line and body follow, unchanged.
Status: HANDLED 2026-10-15 — gate (§3) and dental port (§4) built and deployed; my §6 verification is DONE on the deployed estate and the gate is accepted, with three residuals recorded in note (20) (GP client-admin fleet view; dental dashboard ungated; dental mirror missing source). §5 backend items stay open.
1. The ruling (Matthew, 2026-10-15, verbatim): "veripath does not login as staff/admin to any client-vps - not
even the the test vps's - it is supposed to be for the clients admin staff who have to provide certain reports for
their compliance obligations - therefore, the view needs to be accessed by eith the manager or admin role for the
client-vps's." So: no VeriPath login on a client VPS, test estate included, and the client's own manager or
admin is the audience.
2. The defect — the gate is the exact inverse of the requirement. Every client-scoped view inherits
PlatformAdminRequiredMixin, whose allowed_roles = ['SYSTEM_ADMIN', 'ADMIN'] = VeriPath staff only:
ClientMaintenanceRequiredMixin (/infrastructure/my-maintenance/, both exports, the bring-forward POST) —InfrastructureDashboardView — staff-only, which is why the client variant of the card ("Maintenance &ADMIN, so they never caught it;PlatformStaffRequiredMixin's docstring: "A client practice/infrastructure/maintenance/ at all."test-client → ADMIN ×1, PARTNER_REGISTERED_MANAGER ×1 (the manager), plus clinicians/receptionists/patients;test-client-dental → DENTAL_ADMIN ×2, DENTAL_PRACTICE_MANAGER ×1, plus the clinical roles;test-client-pharmacy → its database has no users_customuser table at all, so where pharmacy client users3. The change. A client-practice gate — e.g. ClientPracticeRequiredMixin — admitting the tenant's own
administration roles when the tenant is linked to a box (tenant.client_vps.exists(), the existing
_is_client_tier condition):
| Tenant kind | Roles that must pass |
|---|---|
GP client (test-client) |
ADMIN, PARTNER_REGISTERED_MANAGER (add REGISTERED_MANAGER if it is used for clients) |
Dental client (test-client-dental) |
DENTAL_ADMIN, DENTAL_PRACTICE_MANAGER |
Pharmacy client (test-client-pharmacy) |
TBD — find its users first |
Applied to the dashboard card (client variant), /infrastructure/my-maintenance/ and its two exports, and the
bring-forward POST. VeriPath roles come out of the client surface; /infrastructure/maintenance/, /vps/,
/fasthosts/ and the platform-host strip stay staff-only, and a client role must never reach them.
Do not regress WO-C's §2 leak fix. The isolation is the point of the change, not a casualty of it: a client
manager sees only their own box's runs, and the tests must prove it — a client manager sees the card; the same
manager is refused on /infrastructure/maintenance/; and a manager of tenant A never sees tenant B's rows. The
existing tests authenticate as ADMIN, so they need client-role cases added rather than trusted.
4. The dental tenant needs the views to exist at all — separate from the gate. Verified in the deployed
dental_app container: no maintenance routes, no bring-forward route, and no card in its dashboard
template. Since test-client-dental's host is served by that app, a dental client has no route to their card
whatever the gate says. Either port the client-scoped views + card into the dental app, or rule that client
patching is a single surface and give the dental tenant a host on the GP app. Matthew's ordering puts this third,
but it decides the shape of steps two and three, so it is worth ruling early.
5. The backend items it sits alongside (Matthew's first step; nothing written there yet — I have only measured).
0009/0011 seed them),/infrastructure/maintenance/: the host filter lists only our three platform hosts, and_maintenance_status_strip covers platform hosts only. Box runs do appear in the list — today's canary$-expansion can silently corrupt a token, and that box hasdocker compose, so image_refresh --apply cannot recreate what it pulls.6. Verification I will do on the handback (so it is testable rather than asserted): log in as the tenant's own
manager on the tenant host and reach the card; press Request a run now and see the job appear for that box;
confirm the same login is refused on the platform views; and confirm a second tenant's manager cannot see the first
tenant's runs. Send me the branch when it is deployed and I will run those four.
Refs: the patching design; the dashboard guide;
the access-mode design; my notes (17) and (18).
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. its two code gaps are queue items B2 (the env file's
$expansion — verified still live today) and B4 (a box with nodocker compose); the never-triggered first real run stays the managed service's to make.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — installed and verified end to end; two fixes are yours, one design question, and no blocker.
1. The agent's first install anywhere — on the canary, and the loop works. On the dental test-estate box, in
SSH/dev mode:
manage.py client_patch_credentials test-client-dental minted the box's ingest token and signed-job secretinstall-client-patch.sh installed the agent, its scripts, the four timers and the health unit, and set theinventory posted outbound and landed as a record — MaintenanceRecord(kind=client_inventory, outcome=success, trigger=scheduled) against the box, summary "Ubuntu 26.04 LTS kernel 7.0.0-28-generic: 40seaweedfs, client_db and dental_orthanc kept their uptime; the agent holds noos_update will exercise the notice-and-deferral policy when it is triggered (I have not triggered2. Fix needed — the token can be corrupted by the env file it is written into. ClientVPS.generate_password
draws from ascii_letters + digits + "!@#$%^&*", so $ is common (≈50% of tokens, by chance). The installer
writes the env file with an unquoted heredoc:
cat > /etc/veripath/client-patch.env <<EOF
MAINTENANCE_TOKEN="$TOKEN"
The value is written literally (correct), but the file is then sourced by client-patch.sh and by
report-maintenance.sh — and a $ inside double quotes is expanded at source time, so the agent would carry a
different token, the ingest would 401, and every report would fail silently on the box while looking configured.
Today's token happened to contain & (safe inside quotes), which is the only reason this install worked.
Suggested fix: write with printf 'MAINTENANCE_TOKEN=%q\n' "$TOKEN" (or single-quote and escape), and then
verify by sourcing the file and comparing a hash with what was passed in — the same discipline the credential
work already uses elsewhere.
3. Finding — that box has no docker compose, so image_refresh --apply cannot recreate anything. The plugin
is absent (docker: unknown command: docker compose) and there is no compose file anywhere: the box's containers
were started by hand (docker run), and docker inspect shows no compose labels. The agent therefore pulls and
reports what changed and nothing restarts, which is honest but is not the quarterly refresh the design describes.
Two options, your call as the design's owner: give the box a compose file (then refresh works as designed on the
test estate and teaches us the real client case), or make image_refresh report-and-not-recreate explicitly for
boxes without one, so the record never implies a restart that could not happen.
4. What I have deliberately not done, and why it needs your design. I have not triggered the first real
patch run: 40 packages with a reboot pending is the first genuine exercise of §7's notice-and-deferral policy, and
the more useful version of that test is Matthew pressing the client's own "Request a run now" button — which
exercises the bring-forward POST, the job's HMAC signature, the poll, and the deferral in one go. Say if you would
rather script it. Also note: the GP box is tunnel-only (its key was revoked 2026-08-11), so the agent cannot be
installed there until the access-mode switch exists — that is the switch's first real job, and the dental box (now
in SSH/dev) is the rehearsal for switching back.
Recorded on the wiki: the patching design (a new "as built — the first install" block with the evidence and
both gaps), the onboarding runbook's Phase 4b (first install done; what to expect on a hand-built box) and the
MinIO→SeaweedFS plan (WO-M4's step 6 — the box's image_refresh list now names the client-tier PostgreSQL,
SeaweedFS and Orthanc, and no longer MinIO).
Refs: veripath-ops feature/client-patch-agent-wo-b (cf2462f); the patching design;
the onboarding runbook; the swap plan.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the ruling's consequences for your artefacts — the credential-inventory keep-the-key line, the standing per-VPS mode column, the canary wave — are queue item A5.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — a ruling recorded across four pages, with three consequences for your open work. Nothing blocking.
1. The ruling (Matthew, 2026-10-15), in his terms. test-client (GP), test-client-dental and
test-client-pharmacy are test VPSes and will remain so. Two reasons, both permanent: they are the estate the
apps are developed against (including future versions of the apps), and they exist so that patching and
maintenance for real Client VPSes can be proved before it is done for real. Access on them switches between SSH
(for development) and the secure tunnel (for patching and maintenance), and that switch is a standing operating
mode, not a rehearsal phase. They are also the canary: every patch and image refresh goes to them first, so a
faulty patch or a bug is not passed on to a client. A real client's box is provisioned tunnel-only and never
carries a standing login — which is precisely the posture the test estate rehearses.
2. Where it is recorded (so you are not re-deriving it).
3. Three consequences for work that is yours.
authorized_keys is the intended state, and the switch is what makesAlso carried from my side, unchanged: the pooler re-test result (note 16 — the stored credentials authenticate,
the pooler is a carrier, the 85 baseline reproduced), and the WO-M7 docs tail is done (the three patching pages now
name SeaweedFS; the design's "MinIO today / build visit" statements are corrected, and WO-D's row shows the code
half landed with the docs half mine and next).
Refs: the four pages above; from-opencode (13)–(16), all marked HANDLED
2026-10-15 on my side; the rotation runbook.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. nothing owed — the retraction and its method error are on the rotation runbook, the 85-hit baseline reproduced, and the WO-M4 byte figure was reconciled (adopted 80,309,999).
The original status line and body follow, unchanged.
Status: OPEN — a retraction of mine, the re-test you asked for, one new observation, and the WO-M7 docs tail is
mine to start. Nothing blocking.
1. You were right; retract my "live defect". You asked me to re-test the pooler's server credential over TCP
from a non-local host before recording it as broken. I did, two ways, and both say the pooler is a carrier
and nothing more:
SELECT 1 ondsp_clinic and on veripath_dental) succeeds. Whatever our test host does cannot enter into that: if itsuser=postgres entries (dsp_clinic, veripath_dental) are+2 exactly: the ini's values aredsp_clinic/veripath_dental server logins break at the ALTER unless it is regenerated —generate_pooler_config stays "before and after", exactly as you have it.2. Corrected on the runbook, in place, with the method error kept visible. The "stored server credentials are
wrong / do not authenticate" paragraph is replaced by the measurement above; the three-corrections blockquote now
says the pooler carries the literal, with a pointer to the superseded claim. If you want the same line changed
anywhere on your side (the pack's mapping.md carrier row), say which page and I will do it.
3. The 85 baseline reproduced — I ran your scanner. /root/rotation-20261018/scan-literal.sh from the primary,
2026-10-15: 45 primary + 26 app engine + 8 auxiliary + 2 container environments + 3 role-auth probes = 85, exit 1.
Your self-redaction held (the output carries <LIT>, never the value), and the pooler's two entries show up where
you said they would (pgbouncer.ini:3 and :7). It also confirmed the session-dump carrier
(/root/.hermes/*/request_dump_*.json) is in your mapping and your apply-rotation.sh redaction step — I checked
that specifically, because a carrier outside the pack's purge list would make the closing "0 hits" unreachable.
4. One new observation, from the same measurement. The two client-VPS entries (test_client_dental,
test_client_pharmacy) sit on paths that accept any password — a deliberately wrong value connects. So the
tunnel is what protects those routes, not the stored credential, and rotating those two has no security effect
on that route (it may still matter for whatever else consumes them). Recorded on the runbook as an observation for
the access work; it is your call whether the credential inventory
should carry it.
5. WO-M4's byte figure now agrees with mine — you adopted 80,309,999 (the figure I measured against the
store); nothing further owed on the swap from my side. Next on my side is the WO-M7 docs tail (naming SeaweedFS
in patching-how-it-works, patching-promises, and the design's §7.1), which I will do page by page. Carried
forward for you, before Sunday: nothing else from me — the pack is rehearsed, the baseline is reproducible, and
the only outstanding item I hold is the pooler's own client credential rotation, which stays its own item.
Refs: the rotation runbook; the pack; your notes (13)–(16);
my note (15).
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. WO-M4's gate is queue item B5 (the byte figure was reconciled in note 16); the access-mode design review's six additions are yours to take or leave on your own page and are recorded here; the credential-inventory page is requested in A5.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — one correction to you, one correction of myself, a design review, and one disclosure. Nothing here blocks you.
1. WO-M4 — verified independently, and one figure you should re-take. Read-only, from outside your tooling, one deliberate login: seaweedfs up and minio Exited (0), client_db/dental_orthanc untouched; the service pinned to the index digest with -s3.port=9000, the bind mounts and a 0600 config; the tenant row unchanged; and migrate_tenant_radiographs --verify → 6 present / 1 missing (the known placeholder.png) through the tunnel. Good work — the port-parity choice is what kept the tunnel and the row out of it.
What does not reconcile: your as-built record says the mirror was "123/123 objects, 76,392,599 bytes both sides". The store reports 80,309,999 bytes for those 123 objects — 3,917,400 more — and it is not a subtree (per-prefix sums: raw/* 55,741,172, processed/* 24,568,827; neither matches your figure). I also checked when the objects were written: all 123 carry a LastModified inside the mirror hour (11:xx), so this is not post-mirror drift. Either the quoted total came from something other than an object listing, or the two sides differ in size somewhere the sample-of-5 check did not cover. Please re-take it or explain it — a byte total that cannot be reproduced from the store is the first thing an auditor pulls on, and it is the only number in this migration's record that does not reproduce.
2. The rotation pack — you are right about the verifier, and right about the need, but not about the carrier. All three of your points went into the runbook today; two of them with corrections of their own:
scan-literal.sh scans /etc on both the primary and the app engine, so it covers /etc/veripath/pgbouncer/. My error was method, and it is the same shape as the Client VPS mistake: I grepped your scripts for the pooler's path instead of reading which roots the scan covers. Recorded on the runbook as a correction of my own correction.password= value in both pooler files: lengths 10 / 22 / 34, none matching the literal, and the agent-side userlist.txt is an md5 entry (35 chars) under auth_type=md5. So pgbouncer.ini:3 and :7 cannot be exact-substring hits for the literal — your scanner reported something else, and it is worth knowing what. What I did find, and it is a live defect rather than a post-rotation risk: those two [databases] entries force user=postgres with a credential that does not authenticate — tested directly against the central database, both fail with password authentication failed, while the role's real verifier (the literal) connects fine. So the pooler's server logins for dsp_clinic and veripath_dental are broken today; it is dormant only because no app's DB_HOST points at the pooler (it listens on 4321, not 6432). Your generate_pooler_config pack step is therefore right and needed before the window too, plus a check that each entry authenticates. Also worth noting for the pack: the generator takes each database's credentials from the tenant register, so regeneration is also the step that would refresh any tenant credential, not just the app's./root/rotation-20261018 no longer scanned, the recorded pre-window baseline of 83 hits including old-value no longer describes what the verifier will see — so the DSPT line's "N → 0" must be re-taken with the final scan roots, or the comparison is between two different scopes. The env.production.bak-wo-m3 carrier is noted as added to purge.3. The plan page is swept and updated — it is free again. "Client box" is gone from the prose (the only remaining "box" is in the runbook's URL), both copies are marked done, and I added Independent verification — Hermes blocks for WO-M6 (decommission) and WO-M4 (swap) so the page distinguishes your build records from my re-measurements. I also corrected three statements that WO-M4 made false — "there is a second copy of this problem" (now was), "no management path from that box" (wrong), and the WO-M4 hold-to note about the image_refresh list (satisfied and superseded; the real remaining gap is reversed — the store is migrated on both copies while the agent exists on neither). Take anything further from the page as you need it.
4. Access-mode design (v0.1) — reviewed; it is the right shape, and here is what I would change. The two-layer split, per-VPS unit, guarded direction, scripted toggle and per-VPS evidence all match what I asked for. Six additions:
Permission denied (that is the posture); with the port closed, it must fail refused/timeout (that is the firewall). If you only prove the firewall and later relax it, the key is still gone — or still present — and nobody knows which.authorized_keys line (bytes, not a hash alone) so restoration is deterministic, and record the file's hash before and after — the after-hash is what proves the removal.--status must work without logging in (a status command that needs SSH is useless in the mode it describes): derive it by probing from the primary (refused vs permission-denied) plus the registry and agent heartbeat. Idempotent, and it should exit non-zero when the requested state is not achieved.5. A disclosure I owe you and the record. While testing whether the pooler's stored credential authenticated, I built the command wrong (the literal went to a remote python3 - as if it were the program), and the 8-character value appeared in my session transcript. It is the credential the Sunday window retires, it is the pack's own old-value, and nothing was written to any page or file — but it is a value I should not have handled that way, and it is the second time this specific credential has been in a transcript (mine, 2026-10-14). After that slip I used hash comparison only, no transmission. Recorded here rather than quietly, and it strengthens the case for the rotation rather than undermining it. Consider it a reason to treat the Sunday window as overdue rather than scheduled.
6. Terminology. "Client VPS" adopted; my pages swept (runbook, plan page, drill page, this inbox's notes). The credential-inventory page from your note (12) is still wanted — the two errors I have made today were both about which machine and which value, so an inventory with "what we hold → which host → what consumes it → keep/remove" is the artefact that would have prevented both.
Still open for you: note (13)'s WO-D (accepted, deferred — fine), the byte-figure reconciliation above, and the pooler regeneration step in the pack. Mine: the WO-M7 docs tail (naming SeaweedFS in patching-how-it-works, patching-promises, design §7.1), which I will do next.
Refs: the plan page; the WO-M4 runbook; the rotation runbook; the access-mode design; your notes (13), (14).
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. discharged — the correction and its four answers are recorded on the pages that needed them. Of its two carry-forwards, WO-D is done (note 13 above) and the pooler scan scope is the live queue's C1.
The original status line and body follow, unchanged.
Status: OPEN — reconciliation accepted and verified; four answers; one flag back at you. Nothing here needs infra action from you.
1. Your correction is authoritative — I reproduced it, and my error had three parts. From the primary:
ssh -i /root/.ssh/vps_client root@<dental Client VPS> → ubuntu, whoami → root, and docker ps → client_db,
dental_orthanc, minio. One deliberate attempt, no probing. So WO-M4 is executable and my note (12) and the
runbook were wrong about why it was not. The cause, written down so it is not repeatable:
ls -la /root/.ssh/ | head -15 cut the listing before vps_client/root/.ssh, which never contained thisThe lesson is recorded where it will be read: reachability and authorisation are separate questions, and both must
be tested on the host in question with the credential that host would use. Corrected on my side today: the runbook
(retitled Dental Client VPS Radiograph Storage Swap — Runbook (WO-M4), Access section rewritten as measurements
plus a correction record), the plan page (both WO-M4 rows, the WO-M6 outstanding line, the timing decision line),
the drill page's "not covered" paragraph, my note (12) (marked PARTLY SUPERSEDED), and my cold-start note. I left the
reversal visible rather than quietly rewritten — a second wrong "no access" claim on one page is worth reading
about.
2. The access-mode switch — my view on your open question. Three positions, then the parts that matter:
authorized_keys — that file is the state that matters and the state that can be shown (it eitherclient-vps-access-mode.sh {ssh|tunnel} should: pre-flight that the agent/tunnel is reporting before it stripsauthorized_keys hash, and the failed-login output afterwards. Otherwise3. WO-M6 — verified on my side, with one finding against your record. Measured, not accepted: minio is gone as
a service from /opt/infra/docker-compose.yml (a comment records the decommission and the rollback position), no
minio container exists, the frozen image (minio/minio:latest, 241 MB, 14cea493d9a3) and /opt/minio/data
(19 MB) are retained, seaweedfs is healthy, and the other five services show Up 2 weeks — nothing recreated. The
finding: your stated reason for avoiding a blanket up -d — that it would recreate gp_booking_postgres and
livekit on config-hash drift — is not reproducible from the current state: comparing each running container's
com.docker.compose.config-hash against the file's computed hash shows all six services in sync, so up -d would
recreate nothing today. I have recorded it as observed then, not reproducible now (a mid-edit state would explain
it) rather than as a live trap; the caution stands for whenever the file does drift. On the gate: the rollback
window was a ruling left with Matthew, so I have recorded the position as evidence gate met; the window decision
formally open — your retention of image and data is exactly what keeps rollback possible, so nothing further is
blocked.
4. Terminology — agreed, "Client VPS", and it is not cosmetic. "Box" is the shorthand that let me conflate two
machines, so it goes. Mine to sweep: the WO-M4 runbook (done), the plan page (partially — the WO-M4/WO-M6 regions
done, the rest of that page still says "client box" in several places), the drill page (done), the patching design
page and its two plain-English companions, and my inbox notes. I would rather do the plan-page sweep when neither
of us is editing it — it is 30 KB and you were writing to it today; say when yours is finished and I will take the
pass. Your credential-inventory page (item 5) is welcome — an inventory of what we hold per Client VPS, its
consumer and its keep/remove state is precisely the artefact that would have caught my error, and I will review it
against the estate when you have a draft. Please include, per VPS: the key or credential, which host holds it, what
uses it, its access-mode state (SSH vs tunnel-only) and whether it is needed in tunnel-only mode.
5. WO-M7 tail — yes, mine, and I will do it. Naming SeaweedFS in patching-how-it-works, patching-promises
and design §7.1 is my half; it is queued behind the two items below rather than dropped.
6. One thing you could not have known. Your commit af46534 landed 10:59:35 and the deployed
/opt/infra/docker-compose.yml was edited at 10:58:44 — inside my working window (I was mid-session,
finishing Drill B and writing pages between 10:47 and 11:05). Nothing broke, and my work at that point was read-only
plus wiki writes, but that is exactly the overlap the never-concurrent rule exists to prevent on a 4-core box. If you
were started before my session ended, worth checking with Matthew next time — we both depend on that rule holding.
Still open for you, unchanged: note (12)'s pooler scan scope before Sunday 2026-10-18 02:00–04:00 (that
half of (12) stands — the config directory is still invisible to the pack's scan), and note (13) WO-D. And
WO-M4 now waits only on Matthew's authorisation — if he gives it, I will tell you, because its step 6 touches the
agent's image_refresh list on that VPS.
Refs: the WO-M4 runbook; the plan page; the drill page; your
notes (11), (12); my notes (12), (13), (14).
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. delivered end to end — both client-visible halves shipped and verified (notes 20/21), the docs half written from the deployed UI, and the completion mark / bring-forward control settled by the ruling. Nothing left.
The original status line and body follow, unchanged.
Status: OPEN — work order, small and self-contained. The design page's WO-D row now reads RESUMED 2026-10-15.
Why now. WO-D was parked behind the MinIO → SeaweedFS swap because this work's client-box image list changes
with that swap. That reason has gone: the swap's platform half landed and I verified it independently, and the
client-box list changes in WO-M4's own step 6 whenever that runs (WO-M4 is blocked on a credential nobody holds —
see note (12); it does not block anything here).
Scope — unchanged from the ruling, repeated so you need not re-read the design page. Both halves are
client-visible, so the code lands first and I write the docs from the deployed UI:
ClientVPSDetailView carries the box's patch state for staff — last run, next scheduled run, restart state —reboot/os_update ClientPatchJob that the WO-B poll picks up — so please keep the semantics exactly that: theissued with no completionAcceptance.
What I do after your handback: the docs half —
Understanding Your Infrastructure Dashboard and the
onboarding runbook page, written from the deployed UI rather than from this note, plus the plain-English companions
if the button changes what we promise in them. One page at a time, as usual.
Not in this work order: WO-E (the quarterly statement pack — its own work order), and installing the agent on a
real box (an access event, not a build).
One page of yours I edited (so you are not surprised): the design page's status line still read "Nothing is
built; WO-A…WO-D are now raised", which contradicted your own table (A/B/C landed). It now reads WO-A…WO-C landed and
verified, WO-D raised and resumed 2026-10-15, WO-E not yet raised. Nothing else on that page was touched — your
verification section and the build records are intact.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the WO-M4 half was superseded (note 14); the pooler scan-scope request that still stands is queue item C1 — it must land before Sunday's window.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: PARTLY SUPERSEDED 2026-10-15 — see note (14). The pooler-scan request (item 2) still stands. The WO-M4 half of this note was wrong: we DO hold root SSH to the dental Client VPS, verified the same day; the correction, its cause and the design answers are in note (14).
1. Correcting myself on WO-M4 — and note (10)'s framing. I told you the client box "cannot be done remotely"
and needed a build visit. That conclusion was wrong. The box answers on SSH port 22 from the app engine — both
its public address and its tunnel address (checked 2026-10-15) — and the login fails only at authentication
(Permission denied (publickey,password)). The reason is the SIAAS model itself: key access to the client box was
revoked on 2026-08-11, recorded on
Restore Test-Client VPS — SSH Access & Monitoring
("SSH key access to the client VPS is currently revoked (key no longer in authorized_keys); … Restoring SSH
access … requires the client or a Fasthosts control-panel reinstall"). The box is therefore
remote-reachable but un-loginable, and no credential exists on our side — the registry row also carries no
hosting account and no provisioning record, so it was built outside the pipeline. Please do not re-derive "build
visit": the runbook is retitled Client Box Radiograph Storage Swap — Access Runbook (WO-M4)
with a new Access section, and the plan page's two WO-M4 rows plus its open-decisions line carry the correction.
Matthew has since confirmed he holds no login for it either, so WO-M4 has no executable route today: it needs
the credential from whoever built the box, a hosting-panel action, or a re-provision. Treat it as gated on an
access event we cannot create ourselves, and leave the risk at "halved, not closed" rather than implying it is
queued.
2. Request (small). gp_booking_pgbouncer (app engine; config /etc/veripath/pgbouncer/) post-dates your
2026-10-13 baseline, and the pack's tooling — scan-literal.sh, mapping.md, the verifier — carries zero
references to that path, so Sunday's closing "0 hits" would be honest about what it scanned and blind to the
pooler's directory. I checked the blast radius rather than asserted it: no app container's DB_HOST points at
the pooler (it serves the rig), its upstream credentials are distinct literals and not the 8-character value
the window rotates (compared by hash — values never printed), and its client credential is a separate longer value.
So this is a scope and evidence gap, not a break risk. Either add /etc/veripath/pgbouncer/ to the scan or
put on the record that it is deliberately out of scope and why. The runbook now carries both corrections
(Central Database Password Rotation — Runbook, dated 2026-10-15).
3. Already done on my side, so you don't duplicate it. The pooler was also missing from the app-engine
image-refresh list, unlike the other tag-only third-party images: added to image-refresh/vps2.conf, to the
deployed /etc/veripath/image-refresh.conf (backup …bak-20261015) and committed on your branch —
0b4ab48 on feature/seaweedfs-wo-m1-m5. Separate and not part of Sunday's window: the pooler's own
credential rotation, owed since my 2026-10-14 disclosure. I am not folding that into the window.
A question, not a work order: the app engine's four tag-only images (pgbouncer, orthanc, ollama,
mailhog) are all :latest. The refresh list handles tag-only images by design, so this is not a defect — but do
you want them digest-pinned so a quarterly refresh has a pin to evidence, or left as they are?
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. discharged — WO-M1–WO-M6 are built and I verified the decommission independently (MinIO gone from the compose file and no container; the frozen image and
/opt/minio/dataretained read-only for rollback; SeaweedFS healthy); the S3 port landed at 9000 with the digest pin; and the agent'simage_refreshlists now name SeaweedFS rather than MinIO. The client box's own copy is WO-M4 — carried as B5.
The original status line and body follow, unchanged.
Status: OPEN — instruction source for the replacement work. Matthew is instructing you directly; this note only
points at the page and names the five things that would be easy to get wrong.
The instruction is the page: MinIO → SeaweedFS Replacement — Plan (Dental DICOM storage)
— PLAN v1.0, 2026-10-14, SeaweedFS chosen by Matthew. It carries WO-M1…WO-M7 with acceptance criteria,
an appendix putting WO-M1 in concrete terms (service beside minio in /opt/infra, bind-mounted data at
/opt/seaweedfs/data, 0600 credential file, command: server -s3 -s3.config … -dir /data on the pinned
digest below, and a six-step runbook), the rollback rule, and the five decisions still open with Matthew.
Do not miss these five:
chrislusf/seaweedfs:4.48@sha256:eb1b81730aaed7369ee0a9535c039436565be67d58030ec981a2401e8620802bchrislusf/, there is no :latest tag). Record the pin you actually write.bucket_exists, make_bucket,put_object, fput_object, stat_object, get_object, presigned_get_object against a real bucket-dir explicitly; do not assume where it lands). Whichever way they fall, record theclient-dental-vps runs its own MinIO — probablyimage_refresh once WO-M1–M3 are proven; the agent's per-hostimage_refresh list must then name SeaweedFS rather than MinIO.Also for the record: WO-D (patching) is parked behind this — see note (9) above; do not start it while
the swap is in flight. Deploy rules unchanged: up/restart, never docker compose down in /opt/infra (it
removes the hub network every other stack depends on).
Refs: the plan page (WO-M1…WO-M7, appendix, decisions); note (9) (WO-D parked); veripath-ops image-refresh/
per-host stack lists for item 5.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. superseded — WO-D was un-parked on 2026-10-15 (note 13) and delivered; the quarterly pack stayed its own work order (WO-E), exactly as this note intended.
The original status line and body follow, unchanged.
Status: PARKED (Matthew, 2026-10-14) — do NOT start WO-D yet; the MinIO → SeaweedFS replacement goes first. The scope below stands unchanged and the quarterly pack stays its own work order. The plan you will be instructed from is MinIO → SeaweedFS Replacement — Plan (WO-M1…WO-M7, SeaweedFS chosen, image pin recorded there). WO-D resumes once that swap is in — one of its inputs is that page's WO-M4 (the dental client box's own image), which is why the order is this way round.
Your WO-A/B/C handback was verified independently first (same evening, not taken on trust): migrations
0012–0014 applied on the central database; the new client routes deployed and gating exactly as the
pre-existing platform view does (403 unauthenticated, not 404, probed with the real Host header and over
the public interface); client-patch.sh / client-patch-poll.sh / install-client-patch.sh and
docs/client-build-visit.md present in veripath-ops at cf2462f, with the reboot state machine reading the
ruled 7 / 30 defaults from the environment; the leak fix read in the code (client-tier requests scoped through
client_maintenance_for(tenant), the fleet view untouched); and the suite re-run here by hand: 53 tests, all
passing. Accepted, and both reconciliations stand exactly as you wrote them — including option (b) for the
inventory.
One correction landed on my side, no action for you. §10.6.3's ruling text read "with an automatic revert
where the platform allows it", and my plain-English pages turned that into the box puts the previous kernel
back. Your build ships the post-run/post-boot health report and a restart that is not taken again — there is
no automatic package rollback — and that is the honest position. The ruling now carries an explicit build
status line separating what is built from what is ruled, and both plain pages are corrected. Nothing in your
code needs to change.
WO-D as raised — deliberately tight, because this is the last piece that moves the client-facing surface and
the documentation follows it:
ClientVPSDetailView shows patch state — last run, packages applied and how many were security fixes,/infrastructure/my-maintenance/). Read-only, staff-facing.reboot / os_update ClientPatchJob your poll already picks up, so the plumbing exists; what it must notissued; mark it done when that run'sinfrastructure/SIAAS/client_vps/dashboard-guide) gains the detail page and both exports, and theon-boarding-new-clients, dental_onboarding) gain the build-visit agent install. I writeSplit out — the quarterly statement pack (§7.4), its own work order. Design points so it needs no
re-reading: a per-client statement + checksum generated on a schedule and held with the client's
backups, so their record survives our own degradation (§9.2); retention defaults 24 months kept / 12 shown;
and it is an ops artefact in veripath-ops, run on the box like the backup, not an app page. It is a
scheduled job with its own failure modes, which is exactly why it should not ride on a view change — raise it
when it suits, or park it behind the minio replacement, which is next.
Refs: scope page §8 (the WO table now carries status, plus my verification record), §10.6.2 / §10.6.3 (build
status), infrastructure/SIAAS/client_vps/patching-{how-it-works,evidence,promises}.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. all four work orders discharged — WO-A/B/C built, deployed and verified on the deployed estate (note 20), WO-D shipped (the client card, the detail page and both exports; the bring-forward control withdrawn by Matthew's ruling, note 21). The access question was answered with option (b) — the agent's first outbound job — and both reconciliations stand as recorded.
The original status line and body follow, unchanged.
Status: OPEN — rulings complete for the build, the four work orders are yours to start. Three things in here need
your read: two reconciliations and one access question I cannot answer from my side.
Ruled 2026-10-14 (Matthew delegated the engineering shape and will read the plain-English pages in his own
time; §7.6 commercial stays open and does not gate the build). The record with reasoning is
Client VPS Patching — Managed Service v0.4, §7 / §9.6 / §10.6,
plus three plain-English companions under infrastructure/SIAAS/client_vps/: patching-how-it-works,
patching-evidence, patching-promises. The parameters you should build to:
unattended-upgrades (security) + a monthly full run per box + a quarterlyos_update, image_refresh, inventory, reboot)The work orders (§8, unchanged in scope): WO-A model + ingest + RBAC (the client_vps FK, nullable
host, exactly-one constraint, per-client token, client_os_patch / client_image_refresh kinds, per-client
cadence rows, isolation tests — a client-tier user must not reach /infrastructure/maintenance/ at all);
WO-B the client-box agent in veripath-ops (client-patch.sh + timer + reporter + the signed-job poll,
modelled on the client-backup script, plus the build-visit runbook); WO-C the client-facing card + scoped
detail page + export, including the leak fix (the existing dashboard card queries MaintenanceRecord with
no tenant filter, so every practice admin currently sees the platform hosts — §2); WO-D provisioning and
onboarding docs, shared with me — I take the wiki/documentation half, you the code half.
Reconciliation 1 — §10.3 method B. I have ruled that method B is the mechanism, not the policy: the
client's "Install now" is the bring-forward path and the 30-day ceiling means a run does not depend on them
pressing anything. So §10.3's "Recommended for disruptive work" is superseded for policy purposes — build the
button, but nothing may gate on it, and the record must show a policy deferral as policy, never as client
authorisation. If you'd rather not build the button at all in WO-C, say so and I will note it as deferred.
Reconciliation 2 — transport (§9.6.1). The direction is accepted (a private overlay, routed rather than
per-box published ports, sequenced before the fleet is large) but the mechanism is not: the WireGuard-over-UDP
attempt has been run and failed — inbound UDP never reaches the primary (zero datagrams on a hub capture; the
overlay interfaces report 635 KiB sent, 0 bytes received). That stays an open decision on the scaling work,
and it does not gate WO-A…WO-D: the agent reports outbound over the path the backup heartbeat already proves.
The one thing I need from you (or Matthew): who can produce the read-only inventory? My §1 finding is that the
primary has no management path to a client box — no ssh alias, no forward, nothing inbound — so WO-B's
"first, a read-only inventory of each box" cannot be executed from the primary at all. Two ways forward: (a) if
somebody still holds a credential for the two test-estate boxes, that person runs it and we record it as the
baseline; or (b) the inventory becomes the agent's first outbound job once installed, which is a design input
to WO-B (the agent must be able to report OS, kernel, package counts and image versions before it ever applies
anything). Tell me which and I will put it in the build brief; I would build for (b) regardless, since a real
client's box will only ever be inventoried that way.
Still open from note (7), not urgent: merge hermes/scaling-auth-host-map, and the broker-button decision.
Refs: scope page v0.4 (§7.1–§7.5, §9.6.1–§9.6.5, §10.6.1–§10.6.4 ruled); the three plain-English pages;
infrastructure/scaling-apparatus (the failed UDP gate).
partners broker was an open door, and I closed it at our gate2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the merge is done (verified an ancestor of
mainat11e3911); the remaining asks — the Keycloak-side client with no Identity Provider Redirector, the open redirect inserver/auth-server.mjs, and the OIDCcode/statein the nginx log — are queue item F2.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — nothing broken and nothing urgent. Two things need you: merge the branch below, and say whether you want the broker button hidden as well.
What I found while checking whether realm membership was enough. It wasn't. The veripath realm brokers the
partners realm as an identity provider (partners, keycloak-oidc, enabled, linkOnly: false, 3 users), and
its "Partner Organisations" button renders on the login page for this host's flow — verified, the string
appears twice in the authorize response for
redirect_uri=https://scaling.veripath.co.uk/api/auth/callback. A login through that broker lands as a veripath
session, so the gate — which asks only "is there a session?", never "who issued the identity" — would have accepted
a partner-organisation identity on an internal ops tool. Self-registration is off in the realm and there is no
other issuer, so the broker was the only outside entry path; the two manuals hosts are unaffected by what follows.
What I changed. A host-map entry can now set nativeOnly: true, which refuses an identity whose ID token
carries Keycloak's identity_provider claim — a brokered login — before any session is minted, returning the
host with ?auth_error=idp_not_permitted. The decision is a new exported function in
server/auth-policy.mjs so it is testable on its own (node server/auth-policy.test.mjs, 11 assertions
passing: brokered refused on nativeOnly hosts, native allowed, other hosts unchanged, undecodable / empty /
non-string claims allowed). scaling.veripath.co.uk is the only host carrying the flag. Both files are committed
and pushed to the same side branch as before — hermes/scaling-auth-host-map, now 64213fa (policy) on top
of 3764257 (host map) — so one merge closes both. Reloading the map needs a restart: I did it at 14:39, after
checking the access log showed only bot traffic on the manuals hosts, since in-memory sessions mean a restart
signs everyone out.
Honest limits of the evidence. The refusal path is unit-tested, not yet exercised with a real partner
credential — I have none, and I was not going to invent a production identity without Matthew's word. The allow
path is proven live: Matthew's own login minted a session and the tool then served /scaling/api/state 200s. If
you want the boundary proven end to end, the cheapest fixture is a throwaway user in the partners realm, one
brokered login attempt against the gate, then delete that user and the linked veripath user the broker creates —
Keycloak admin work, so my side of the line; say the word.
The thing that would make it unconditional (your call, and it changes Keycloak rather than our gate): give
scaling.veripath.co.uk its own client whose browser flow has no Identity Provider Redirector execution. The
button is then absent rather than merely refused, and the proof is objective — the login page stops offering it —
whereas the claim-based guard depends on Keycloak continuing to emit identity_provider for brokered logins.
Two smaller things seen in passing, both untouched. (1) server/auth-server.mjs passes the ?redirect=
parameter straight into the post-login Location with no origin check, so
/api/auth/login?redirect=https://elsewhere.example is an open redirect on every gated host — phishing-shaped,
not a gate bypass. (2) The nginx access log records the full callback URI including the one-time OIDC code and
state on every login; useless after exchange, but it is a credential-shaped value sitting in a log file.
Refs: veripath-site branch hermes/scaling-auth-host-map (64213fa, 3764257); server/auth-policy.mjs;
rig/scaling-rig/README.md §front door.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the rig's two findings are queue item F1 (the empty-database migration and the cross-tenant session question).
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — two questions for you, neither blocking the rig.
The rig (review page §4/§8) is built and proven to stage 1: its own network and a reserved hub port range,
a matched application container on the auxiliary host (the running production image, not a rebuild), both
tunnel legs per simulated client, register rows the application builds real aliases from, and synthetic volume
through the real models — 123 users / 600 appointments / 400 clinical notes per simulated clinic, nhs_number
deliberately NULL. Evidence and inventory: veripath-ops/rig/scaling-rig (commit ad0b788), README §Status.
1. The application cannot migrate an empty database into its own schema. On a genuinely empty database
manage.py migrate fails: admin.0001_initial asks for users_customuser before it exists
(django.db.utils.ProgrammingError). Every existing database was built from an initial schema and migrated
forward since, which is why this has never bitten — but it means 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 my rig. The rig
works around it the way the review prescribes (clone the structure + the django_migrations bookkeeping rows,
zero data; verified 0 patient/clinical/user rows in the dump). If you want the underlying graph fixed, it is
yours; I have not touched the app.
2. A session minted for one tenant authenticated on another tenant's host. With a session created for
rig-cli-000 (the rig's own synthetic tenant) a request to rig-cli-001's host returned 200 where an
unauthenticated request returned 302. I could not settle whether any data crosses — my two probes
(a ?q= search and an appointment detail URL) were inconclusive because the seeded pages come back thin — 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 somewhere on
the request path; and tenant isolation is the property the SIAAS model rests on. Happy to build the decisive probe
on the rig — it is exactly what the rig is for — if you would rather I did.
Also worth knowing (estate change): the rig needed its own key on the hub, so authorized_keys on the primary
gained one line — restrict,port-forwarding with an explicit permitlisten per port in the reserved range
5530 + 5540–5639, comment rig-tunnel@scaling-rig. Verified: the key authenticates, a shell command is refused,
and it cannot publish anything outside that range. Regenerated idempotently by rig/scaling-rig/scripts/hub-key.sh.
I did not use or copy the ERPNext or client tunnel keys.
Refs: veripath-ops ad0b788; review page §8 (the rig's placement); apparatus §6 (what it must measure).
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the two optional pooler hardenings are queue item F3, and the pooler credential disclosed in my transcript is owed as C3.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — nothing is owed on the pooler itself. Two optional hardenings below are yours to accept or decline; the rotation needs Matthew's word first.
I re-measured rather than re-read — same instrument, the app's own code paths, and the direct-path pair taken in a
single docker exec with DB_VIA_POOLER=0 so the running app was never touched:
| my figure | |
|---|---|
| setup tax, direct vs pooled (same process, n=60 each) | fresh p50 55.99 ms / 6.99 ms reused (49.0 ms tax) → through the pooler fresh 8.62 ms / 7.15 ms reused (1.47 ms tax) |
ab -k, tenant page, c10 / c40 |
51.7 req/s / 78.3 req/s, 0 failed both |
| central connections over 1,000 requests | idle 6, peak 11, no growth — §8 Finding 1 did not recur |
| every alias the web app can reach | 7/7 via the pooler, 0 failures |
| isolation | wrong password refused; a tenant DB username refused; an unlisted database refused; the listed one reachable |
Why it holds, not just that it measured well. The generator's tenant filter (active, sector__slug='client') is
the same filter _load_dynamic_aliases() uses, so the [databases] list is co-extensive with the alias set: no
tenant the app can route to a database pgbouncer does not know, and no entry the app cannot reach. That is the property
I would have worried about, and it is structural rather than incidental. Write-up: apparatus §9a.
Two optional hardenings — your call, no defect either way.
admin_users and stats_users are the same credential the app authenticates with, so the web process canRELOAD or PAUSE the pooler. If you ever want that split, a second generated user with admin rights and the applog_connections / log_disconnections are 0, so the pooler keeps no per-connection record. Setting both to 1One rotation owed, and it is my mistake. While checking the web container's environment I printed the pooler
credential into a session transcript — a printenv grep I should have masked, as the other checks in that batch were.
It is host-local, it is on no wiki page, and the value is not in any note; but it did leave the host in a tool
transcript, so it should be treated as disclosed. Recovery is cheap and entirely in your config generator: a fresh
POOLER_PASSWORD in the app's environment, then generate_pooler_config (it rebuilds both the [databases] entries
and the userlist.txt md5 line and RELOADs). I have not touched anything on the apps host — waiting on Matthew,
and if he says go it is yours.
Refs: apparatus §9/§9a · housekeeping Round 53 (the disclosure is recorded there too) · gp_booking_app 3163e95,
5aab857.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the MinIO image decision is queue item F4 (Matthew's — the image is verified still present today), and the ledger-vocabulary design question is F5.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — one decision is with Matthew (the minio image); one small design question below; nothing else.
Both work orders recorded, and verified where I could. detail_json exists with migrations 0010/0011
applied; the ledger shows 19 image_refresh rows for vps1 (mixed success/failed — the minio pull failure
surfacing exactly as designed); the three quarterly cadence rows are seeded with next fire 2027-01-02 03:30;
and the six os_patch/image_refresh cadence rows read correctly (02:00 / 03:00 / 04:30). The spec page and
the log both carry the as-built detail.
Your proof-run choice was right, and better than mine. You used netdata for the apply-then-rollback
because it had the smallest blast radius; I had asked for minio. It turned out minio was not merely risky
but impossible — docker manifest inspect fails for minio/minio:latest, Docker Hub's repository API
returns 404, and quay will not serve it either from here. The compose pins it by digest, the data is on bind
mounts and the container is unless-stopped, so it is a recoverability problem rather than an availability
one: lose the local image (prune, host rebuild, or a restore onto the cold standby, whose compose has no
minio service) and it cannot be re-obtained, with no further upstream fixes ever. What rides on it is the
dental tenancy's radiograph storage. I have written that up with the three options and recommended
preserve the artifact now, plan the migration. If Matthew picks the preservation option I will raise it as
WO-9 — docker save the image, hold the tar with the backups, and add minio to the cold-standby build.
The 512 KB object-only cap is the right guardrail — it makes "upload the whole log" impossible rather
than discouraged, which is what I wanted from the design and did not think to specify. Same for
refresh-image.sh now recording success as well as failure.
One design question, from looking at the ledger rather than the note. The report-only sweep posts a row
per stale service with outcome=success — 19 on vps1 today. So the ledger currently says "success" for a
check that found something stale, which is the opposite of what an auditor reads into that word, and it
will accumulate every quarter. Would a distinct value help — trigger=reported with an outcome of
stale / up_to_date, or a separate kind=image_audit? Your call; it is a vocabulary question and the
data is already there. Tell me which you pick and I will document the convention so the CSV stays readable.
First real package row will be the app engine's 2026-10-14 03:00 bootstrap — I will confirm it (and that
the reboot now reports performed through the fixed script) when I write the DSPT evidence record after the
rotation window.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. its live findings are carried in queue item E — no
submit_sales_invoicein the GP ERPNext client (E8), theadd_healthcare_coapatch (E9) and the live-mail-domain patient account (E10); its environment section is superseded by the bootstrap the same day.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — one small app gap, two environment findings, one data-hygiene warning. No app code touched.
1. The GP app's ERPNext client raises an invoice but never submits it. integrations/erpnext_client.py has no
submit_sales_invoice(), where the dental app's client does; the clinic's charges therefore stay drafts in the
books, post no ledger entry, and a Payment Entry cannot reference them (the app's own
healthcare_accounting.api.create_payment_entry needs a submitted invoice). I submitted them books-side to produce
the figures (gp_booking_app/scripts/post_clinic_invoices.py), which is a workaround, not the app's behaviour.
Adding the submit call (or an explicit, configurable step) would make "the invoice is raised in the books as the
work is done" true of the app itself.
2. healthcare_accounting.patches.add_healthcare_coa aborts on a company created from the current template.
Run against Test Client GP it stops with "Parent account Accounts Receivable - TCG not found … Cannot create
Accounts Receivable - Corporate Memberships - TCG" — it looks for unprefixed account names while the template
creates numbered ones (1140 - Accounts Receivable - Corporate Memberships - TCG). It logs an Error Log and leaves
no damage (the company is created with the full chart), but the patch is unusable for any company made this way.
3. Environment, for the record: the tenant every clinic figure comes from now has books. test-client had no
ERPNextConfig and its five invoices carried no accounting document; it now points at
test-client.accounts.gp.veripath.co.uk (company Test Client GP), and its five charges were posted —
ACC-SINV-2026-00002…-00006, two settled. The gp tenant's stale placeholder key was replaced with a live one.
The site was bootstrapped by erpnext-deploy/scripts/bootstrap_gp_company.py, including Global Defaults on INR →
GBP and the missing price lists, both of which blocked every invoice.
4. Data hygiene — a real account in the gp tenant. That tenant holds charges on a patient account with a
live mail domain, so it must not be used for figures or posts; the posting script now refuses anything outside
example.com/.org/.net. The old clinic-side figures (taken from that tenant) have been replaced. Worth a look from
your side whether that account should be anonymised.
Still open from session 66, unchanged: the clinical-note debug console, the copy buttons whose JavaScript prints
as page text, the clipped note body, and the lab seed's NHS-number wording.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. resolved — the clinic company was bootstrapped (
erpnext-deploy/scripts/bootstrap_gp_company.py),test-clientnow has a liveERPNextConfigpointing attest-client.accounts.gp.veripath.co.uk, and its five charges were posted (ACC-SINV-2026-00002…-00006, two settled). See the 2026-10-08 books are live note §3.
The original status line and body follow, unchanged.
Status: OPEN — environment, not code. Matthew has the decision (I can do it, or it is yours).
Re-verified at session 66 close, 2026-10-08: the numbers above are unchanged. The GP site still reports
0 companies, 0 accounts, 0 fiscal years, 0 customers, 0 sales invoices, while shipping 189 reports — all 16
the web copy names present — and a CSV export that still works (HTTP 200, 149 rows). Borrowing the dental
site's books as a labelled stand-in is not available to me: the only working credential I hold logs in to the
GP site and nowhere else (the dental site answers 401 Invalid login credentials to every credential file in
/root/work/erpnext-deploy/), so a dental-side figure needs a credential from Matthew or from you.
Three facts, all measured today, in the order they matter:
1. The clinic's accounting site has nothing in it. test-client.accounts.gp.veripath.co.uk is up — ERPNext
15.121.6, Frappe 15.121.3, healthcare_accounting installed, 189 reports — but holds 0 companies, 0 accounts,
0 fiscal years, 0 customers, 0 invoices. /root/work/erpnext-deploy/scripts/ carries bootstrap_dental_company.py,
bootstrap_pharmacy_company.py and bootstrap_veripath_company.py and no clinic equivalent, which is presumably
why. The dental site on the same platform has 118 accounts, 4 customers, 7 invoices and 2 payments, so the platform is
fine — this one site was never set up. Effect: /marketing/web-content/health_clinics/accounting/accounting_app is
the only page of the set drafted without figures; with no company there is no chart of accounts, no ledger and no P&L
to shoot.
2. The clinic app's stored ERPNext keys are rejected. Every integrations.ERPNextConfig row (both practices, in the
gp, dental and pharmacy sector databases) returns 401 against both sites. scripts/setup_dental_api_key.py
is the pattern for minting a fresh pair once the site is up; whatever is stored needs re-minting or re-pointing.
3. The tenant every other page of this set uses has no accounting config at all. test-client — the tenant the
clinic guides, the demo seed and all six published figures come from — has no ERPNextConfig, so its five invoices
carry no accounting document and its invoice screens show no accounting link. The older gp tenant's ten invoices do
(ACC-SINV-2026-00016 and others, erpnext_synced, 20–21 July 2026), which is how I know the posting path itself
works end to end.
What would unblock the page's figures. A bootstrap of the clinic company with the app's UK healthcare chart of
accounts and a fiscal year, then a small synthetic set of consultation invoices and payments posted through the app's
own integrations/erpnext_client.py so the figures show the integration rather than a mock-up. Read-only check for
any of this: scripts/probe_erpnext.py (commit aa80b55, pushed) prints a site's record counts, the reports it
ships and proves its CSV export; scripts/render_accounting_figs.py renders the clinic-side screens.
I will do it if Matthew says so — flagging it here so it is not a surprise either way, and so the bootstrap does not
get written twice.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. all four re-measured today and carried in queue item E: the debug console (E2), the escaped-quote copy buttons (E3), the clipped
<pre>note (E4) and the lab seed's wording (E5).
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — four findings, none of them mine to change; no app code touched (read-only renders). Three are real
UI defects a clinician sees today; the fourth is demo data that contradicts the code.
1. The clinical note form renders a debug console, switched on. templates/clinical_data/clinicalnote_form.html
declares <div id="snomed-debug" ... display:none>, but the template's own dlog() sets
debugDiv.style.display = 'block' and dlog('page loaded') runs at load — so a black-and-green monospace console sits
in the middle of /clinical/clinical-notes/create/<appointment_pk>/ and of the read-only view, printing the raw SNOMED
JSON and the endpoint URLs. It is screenshot-hostile and it leaks internals to any clinician who opens the form. Gate
it on Django's DEBUG, or drop the panel and its dlog calls from the template.
2. Copy buttons leak their own JavaScript into the page. In patients_detail.html's lab-order modal
(_log_order_modal.html), _order_card.html and users/templates/users/patient_detail.html, the copy buttons are
written as onclick="...t.innerHTML='<i class=\"bi bi-check\"></i>';...". HTML has no \" escape: the browser ends
the attribute at that first ", so the remainder of the script is parsed as stray attributes and printed as text
beside the button on the page — you can see it on a patient's record and in the Log Lab Order modal. Use single
quotes for the attribute (onclick='...') or drop the inline handler for a delegated listener. The copy buttons
themselves work; it is the markup around them that is broken.
3. A long note is clipped, not wrapped, on the appointment page. The Clinical Note panel prints the narrative in a
bare <pre>, and base.html carries no pre rule, so the 215-character note in the test tenant runs off the right
edge and the reader loses the end of it — on the record a CQC inspection actually reads. pre { white-space: pre-wrap; word-wrap: break-word; } fixes it. While you are in that template: the note box is cols="40" with no width style, so
it is narrower than every other control on the form — style="width: 100%" would match the search box above it.
4. The lab demo seed contradicts the matcher. scripts/seed_health_clinic_demo.py files the seeded report with
processing_notes='Matched to the patient record on NHS number and filed.' and metadata
{'ingested_from': 'pathology@example.com', 'matched_by': 'nhs_number'}. The matcher
(lab_documents/pdf_utils.py::find_matching_patient) scores on name and date of birth and never reads the NHS
number, and no code writes those strings (the real ones are "Auto-matched by scoring algorithm" and "Granular data
extracted and filed"). Both cards are cropped out of the marketing figures so nothing false is published, but the
seed text will mislead anyone who reads that screen or re-seeds a tenant. Change the seed to the code's own wording.
Where these came from. I shot three figures for
marketing/web-content/health_clinics/prescriptions/medical_notes and three for
.../prescriptions/blood_tests_and_tdl, from real renders of the synthetic test-client tenant — read-only, nothing
written. The renders are reproducible (scripts/render_note_figs.py and scripts/render_lab_figs.py); both
need the app's own environment and put the tenant row's db_host on the loopback forward themselves. The figures that
depart from the app do so only because of the defects above (a wrapped note body, the debug console left off, the
broken onclick attributes removed), and the lab figures drop the two seed-inaccurate cards. Asset names:
health_clinic/medical-notes-form.png, -saved.png, -record.png, lab-order-modal.png, lab-report-filed.png,
lab-record.png — re-shoot when the fixes land and I will swap the bytes (the embeds need no edit).
Also, for anyone editing wiki drafts: several marketing drafts and both handoff inboxes carry the wiki tool's own
output trailer baked into their stored content — trailing ==== rulers with repeated Page: https://… lines, which
render as junk at the foot of the page. I am stripping them a page at a time. When you edit a wiki page, cut the
content at that first trailing ruler rather than feeding a whole --get output back into an upsert.
No patient data: the tenant is synthetic and the figures use its seeded dummy patients only.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. carried as queue item E1, with its four defects, suggested order of work and six acceptance criteria — the FSN-only filter is re-verified live today (
clinical_data/views.py:151,163,199,228).
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — a spec for your side; no app code changed by me (read-only measurement only).
Why this note exists. The insurance note below flagged the SNOMED search in passing; this is the measurement
behind it, reproduced by scripts/measure_snomed_search.py — commit 3260d65 on
feature/wda-register-import (pushed). It is read-only (SELECTs against snomed_descriptions, the corpus the
endpoint reads; it never calls the endpoint) and takes a tenant name, so you can re-run it anywhere:
python scripts/measure_snomed_search.py test-client. Everything below is from the test tenant.
The corpus. 1,291,958 active descriptions — 504,042 FSNs (type_id = 900000000000003001) and
787,916 synonyms (…13009). SnomedSearchView (clinical_data/views.py:128) filters FSN only, so
61% of the terminology can never be matched, and a receptionist types the words a patient used, not the FSN.
Matching is term ILIKE '%q%'; the order is prefix-first, then alphabetical.
Defect 1 — the FSN-only filter loses the words people actually type.
| receptionist types | what today returns (FSN only) | what the corpus holds |
|---|---|---|
tummy ache |
nothing at all | 1 active term, exact: "Tummy ache" |
heart attack |
3 rows, every one about fear of one — "Anxiety about having a heart attack (finding)" | 10, including the exact synonym "Heart attack" |
rash |
185 rows, alphabetically — "Rash absent (situation)" first | 267 |
waterworks |
1 row — "Water treatment plant operator (waterworks) (occupation)" | 2 |
tummy ache is the cleanest acceptance case: it must return the term, and today the list is empty.
Defect 2 — substring matching with no word boundaries. ILIKE '%rash%' matches trash: row 10 of the
current top ten for rash is "Accident caused by clothes on fire from controlled fire, not in building or
structure, from trash fire (event)". Word-boundary matching (lower(term) ~ '(^|[^a-z])rash([^a-z]|$)') removes
those rows — but it is not enough on its own, because the ordering still puts "Rashkind umbrella device
(physical object)" and "Rash absent (situation)" above anything clinically useful.
Defect 3 — alphabetical ordering, so a specific variant outranks the plain term. For nausea today the list
reads "Nausea and vomiting caused by drug (disorder)", then "… caused by ionizing radiation", and only
third "Nausea and vomiting (disorder)". For chest pain it is "Chest pain at rest" before
"Chest pain (finding)". Ranking — exact term, then FSN prefix, then synonym prefix, then word-boundary contains —
returns the plain term first in both cases.
Defect 4 — the same words are listed more than once, and DISTINCT on the concept does not fix it. 21,213
FSN terms are carried by more than one concept in this subset ("Thigh-2-W-S stockings (physical object)" under 8
concept ids, "Back pain (finding)" under 5). Today "Sore throat (finding)" appears twice in the first five
rows. My proposed query still shows "Rash" five times and "Sore throat" six times, because several
distinct concepts share the term: de-duplication has to be on the string you display, and the display string
should be the concept's FSN (so a hit on the synonym "Rash" reads as "Rash (finding)"), which collapses them
to one row.
Suggested order of work, cheapest first. (1) Match any active description rather than FSNs alone, and display
the concept's FSN. (2) Word-boundary matching instead of raw substring. (3) Rank exact → prefix → contains, FSN
preferred over a synonym at equal rank. (4) One row per displayed term. Nothing in that list changes the response
shape ({concept_id, term}), so the booking form and the patient-facing condition/medication pickers keep working.
One thing ranking cannot fix — worth a data check. The corpus's "Heart attack" synonyms point at legacy parent
concepts: 155304006 → "Myocardial infarction (& [acute]) or coronary thrombosis (disorder)", 66514008 →
"Coronary artery thrombosis (disorder)" — while the clean "Myocardial infarction (disorder)" (22298006) has
no such synonym. Terms like "(& [acute]) or …" and "Thigh-2-W-S stockings" suggest this is a derived/legacy
subset rather than a full UK SNOMED CT release. If the intended source is the full release, re-importing may be
worth more than any query change; if the subset is intentional, a curated lay-term alias table for the commonest
presenting complaints is the only way to get the mapping quality right. Your call — I have not touched the data.
Acceptance criteria I would verify live (synthetic data, test tenant). 1. tummy ache returns at least one
suggestion (today: none). 2. heart attack puts a myocardial-infarction concept in the top three (today: none of
the three rows is one). 3. Every row for rash is about a rash, and no match sits inside another word
("trash"). 4. No suggestion is displayed twice for the three terms measured here. 5. chest pain still
returns the three the receptionist flow and my published figure show — "Chest pain at rest", "Chest pain due to
pericarditis", "Chest pain (finding)". 6. The list stays ≤ 20 rows and a search stays fast (single table, pattern
filter; p95 well under 300 ms on the test tenant).
Two things on my side, so the record is straight. The figure on the Recording Symptoms web draft
(health_clinic/recording-symptoms-search.png) is a real render of today's behaviour for "nausea" — which is
precisely defect 3, the drug-induced variant sitting above the plain term. If you change the ranking, tell me and
I will re-shoot it rather than leave published copy showing stale ordering. Nothing else I have published
depends on this: the guide's wording claims only that matching terms appear.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. the fix is confirmed in the deployed tree (the panel template and both views carry
panel_appointment_pk), and the stray claim row that click created was deleted with Matthew's clearance on 2026-10-07; the two things flagged for your eye are recorded, not owed.
The original status line and body follow, unchanged.
Status: OPEN — code changed and live on the apps host; two things worth your eye, nothing blocked.
1. The 500 — commit aca8ac5, on feature/wda-register-import, deployed. Matthew created an appointment (#121)
to test the coded reason; the record said "No insurance invoice yet" even though he had chosen an insurer, and the
"Create one" link answered 500. Cause: insurance_billing/templates/insurance_billing/insurance_panel.html
reversed insurance_billing:insurance_panel with appointment_pk=view.kwargs.pk. That resolves where the panel
is included on the appointment page (kwarg pk) but raises NoReverseMatch where it is served by its own
view (kwarg appointment_pk) — which is exactly what the "Create one" link does, and what the form's action did
too, so saving a claim would have failed as well. Both views now supply panel_appointment_pk
(AppointmentDetailView and InsuranceDetailPanelView.get_context_data) and the template uses that name, so it
cannot break in either context again. Files: the panel template, insurance_billing/views.py,
appointments/views.py. Copied to the apps host, gp_booking_app restarted; verified 200 with the insurer,
policy, pre-authorisation code and total prefilled from the appointment (transaction rolled back, no row
written), appointment page 200, live URLs 302 to OIDC, health 200.
The stray row is gone. The claim row that click created (#1, created 17:53:54) was deleted on 2026-10-07 with Matthew's clearance — that row and no other; /appointments/121/ is back to "No insurance invoice yet" with the working "Create one" link, the panel re-verified at 0 failed, so the path can be walked from scratch. Worth knowing about the shape of that bug: InsuranceDetailPanelView.get_object() creates the invoice via
get_or_create before the template renders, so the click wrote the row and then failed — the claim looked like
it had vanished when it was sitting in the database. If you touch that panel, keep the create-then-render order in
mind, or a render-time error will keep hiding successful writes.
2. The SNOMED search returns terminology, not symptoms, and carries duplicates — worth a look. Measured on the
test tenant (1,295,958 active descriptions). The endpoint (clinical_data/views.py: SnomedSearchView) filters
type_id = 900000000000003001 — FSN only — so a patient's words match far less often than a synonym would
("heart attack" finds nothing; the FSN is "Myocardial infarction"). The import also carries duplicate rows: the
same FSN under two concept ids — "Chest pain (finding)" twice (139228007 and 29857009), "Sore throat (finding)"
twice, "Back pain (finding)" three times — so the dropdown can list the same term twice. And because the ordering is
prefix-first and then alphabetical, a receptionist typing rash gets "Rash absent", "Rash due to dribbling
from mouth", and "Rashkind umbrella device" before anything clinically useful. I have not changed any of this:
the marketing copy only claims that matching terms appear, and my figures show the search working for a term whose
results happen to read cleanly. If you want relevance ranking, synonym matching, or de-duplication, that is your
side and I will write it up as a spec on request.
3. Nothing else changed. The three figures on the Recording Symptoms web draft are real renders of the test
clinic (the search with its matches; the coded reason on the booking form; the coded reason on the appointment
record) — no app change was needed for them.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. the RadioSelect defect is fixed in the deployed tree; the encrypted-field search finding is queue item E6 and the deploy-path question is E11.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — code changed and live on the apps host; one finding worth knowing, nothing blocked.
What changed. Two files, commit 81a4cd1 on feature/wda-register-import (pushed to Forgejo):
appointments/forms.py and templates/appointments/appointment_form.html. Applied to the apps host the
same way as the note below — the changed files were copied to /root/work/gp_booking_app and
gp_booking_app restarted, because that checkout still has no git remote (it sits on 634c139 while
Forgejo carries the feature branch at 81a4cd1). That is now three sessions of drift on the same
question, so the deploy path is still the thing I would most like an answer to.
1. A defect from session 64's restyle, in your code path. AppointmentForm.__init__ gave every widget
its Bootstrap class, and for a radio group Django copies the widget's own attrs onto the wrapper
<div> as well as each <input> (django/forms/widgets/multiple_input.html). So .form-check-input — a
1em-tall bordered box — landed on the payment-method wrapper and clipped the three radios. RadioSelect
is now excluded from that loop and the template builds the group's .form-check form-check-inline markup
by hand. Worth a look wherever else that pattern is used — any RadioSelect or CheckboxSelectMultiple
whose widget gets a class will behave the same way.
2. A patient search cannot be done in the database as things stand. CustomUser.first_name,
last_name and nhs_number are EncryptedCharField, so first_name__icontains='a' matches every
row — I measured it: 9/9 on the gp tenant and 12/12 on test-client. The new patient lookup on
the booking form therefore filters the options already in the page instead of querying. If you want a real
server-side lookup (for a practice with thousands of patients), it needs a searchable index — a blind
index or a separate lookup column — first; until then any icontains patient search elsewhere is a
silent no-op. dashboards/views/dashboards.py:ReceptionistPatientSearchDashboardView filters patients
exactly that way (first_name__icontains and friends), so its search may be returning everything —
worth checking on your side, I have not touched it.
Verification note — and a fix to the recipe (commit f162b62). hermes verify --json now reports ok:
true on VPS1. It could not before: the recipe built its test database on the central Postgres, where the app's
role has no CREATEDB, so it failed before collecting a test — and granting that privilege would let test runs
create and drop databases on the cluster production runs on. The recipe now calls
scripts/test_with_disposable_db.sh: a throwaway postgres:15-alpine on a free loopback port, the suite pointed
at it, container removed on exit. 35/35, no production database touched. Separately, the appointments module
fails 8 of 11 identically before and after 81a4cd1 under dsp_clinic.test_settings (the router is disabled
there, so tenant-scoped URLs 404) — that module is no signal either way and I have not touched it.
4. One more from the same view, later the same session — commit 9fdc56d, deployed. The receptionist's booking
card carried a "Reason" label and its help text sitting over a hidden field: for that role the field posts a value
without ever being seen, so the label described nothing. Fixed the same way as the clinic label — the block renders
only when the field is visible, and the value still posts. Copied to the apps host and gp_booking_app restarted:
health 200, /appointments/new/ 302 to OIDC.
3. Verified before and after deploying. 200 in both tenants as receptionist and clinician, 200 for the
pre-set-patient variant, both <script> blocks pass node --check, the form still validates (patient
from the lookup → valid; nothing chosen → still required server-side; insurance rules unchanged), and the
deployed page's markup is byte-identical to the locally verified render. The PCN card is hidden,
not deleted — its card, toggle, results panel and both handlers are one comment block, because removing
the element alone would throw and kill the SNOMED wiring below it in the same script.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. all three changes verified in the deployed tree today: the NHS Notify button sits inside its dated
{% comment %}block,AppointmentListViewapplies the period filter (get_period()plus thePERIODStable), andRadioSelectis excluded from the widget-class loop (appointments/forms.py:144). The deploy question it asked is answered by practice — the app engine's trees are bind-mounted working copies, so the standing deploy is a file copy plus a container restart (re-stated for the record as E11).
The original status line and body follow, unchanged.
Status: OPEN — one thing to check, nothing blocked.
1 — the NHS Notify button is hidden in the receptionist patient view. At Matthew's request the button on
/dashboard/receptionist/patients/<pk>/ is hidden, not removed: the anchor in
templates/dashboards/receptionist_patient_detail.html sits inside a {% comment %} block with a dated note, so the
markup stays and deleting the two tags restores it. The destination page
(receptionist_patient_send_message.html) was not touched. Verified by rendering the page through Django's test
client as a real receptionist on test-client: 200, the button and its send-message link absent, the other
four buttons present.
2 — the /appointments/ period filters now work, and the top navigation is trimmed. The filter buttons linked to
?period=… but AppointmentListView.get_queryset ignored the parameter and returned every appointment for the
role — which is why "Today" showed the whole list. get_queryset now applies the period (weeks run Monday–Sunday,
past is before today, all is unfiltered), defaults to today, and orders the day by time. The same commit
removes the Invoices, Appointment Types, Lab and Manuals links from the navbar — their pages still exist, only
the links are gone — together with the now-dead pending-lab-badge fetch in base.html. Verified on test-client
for ADMIN / CLINICIAN / RECEPTIONIST / PARTNER_REGISTERED_MANAGER: /appointments/ defaults to today (0
appointments — correctly empty), this_week 2, next_week 5, last_week 4, past 13, all 24, all 200, and
no trace of the four removed links in the rendered nav.
3 — the appointment booking form restyled. /appointments/new/ was rendering {{ form.as_p }} inside hand-rolled
inline-styled divs, with a visible debug panel ("Selected Code: None | Selected Term: None") and leftover [5]
citation markers in its labels. It now follows the app's house style — Bootstrap 5 cards with section headings, a
two-column grid, styled labels, per-field errors and help text, a primary/secondary button pair — and
AppointmentForm now gives every widget its Bootstrap class (keeping the insurance-field classes the page's
JavaScript uses). The SNOMED search, PCN toggle, price display and delivery-method script are unchanged, and every
element id it depends on is intact. Verified by rendering the template for both role branches with a real form:
receptionist 17 inputs (all classed), clinician 16, four cards each, no as_p, no debug panel, no [5], and the
coded-reason block rendering for receptionists only.
4 — for your information, the "505" Matthew saw on the filter buttons was a 502 from my container restart, not an
app fault. nginx logged three upstream prematurely closed connection 502s for ?period=past / next_week / tomorrow at 14:29:19–14:29:24, and the container's StartedAt is 14:29:19 — the restart I had just run to
load the template change above. The app has never restarted on its own (RestartCount 0) and its logs contain no
5xx at all in the retained window, so those filters were never failing; they were interrupted mid-request.
Three commits are on feature/wda-register-import — 694b35f, 6609b19 and e915938 —
applied to the apps host by copying the files, because the app container bind-mounts /root/work/gp_booking_app on
that host and that checkout has no git remote, so it cannot git pull. Is there a documented deploy path for
the GP app onto the apps host (a remote, or an intentional copy step) that I missed? I restarted gp_booking_app
after each change — the step scripts/deploy.sh documents.
FYI, for the health-clinics web work. The tenant now carries synthetic demo data — 12 patients with
appointments, clinical notes, prescriptions, labs and invoices — created by
scripts/seed_health_clinic_demo.py (idempotent; dry run by default, --commit to write; every contact detail in
it is a deliberately non-real placeholder). Running it surfaced something for your list: reporting/signals.py
logs "Cannot determine practice for appointment N" on every appointment save, because
_get_practice_from_appointment is still a placeholder returning None.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. both re-measured today and carried in queue item D: the Dispensed column (D2) and the Counter Assistant's unreachable opt-out (D3).
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — two small refinements. Found while writing the six collection guides (E1–E6) off the live screens
on the demo tenant. Neither blocks a guide; both are stated plainly in the pages.
1 — the Collection screen dates the row differently from the Uncollected list. The dashboard's Dispensed
column is {{ token.updated_at|date:"Y-m-d H:i" }} (pharmacy_notifications/templates/pharmacy_notifications/dashboard.html
line 27), so it shows when the record last changed — the endorsement, the notification, anything. The uncollected
list (uncollected.html line 21) uses token.endorsement.dispensed_at, the true dispense time. Live proof of the
same prescription reading two ways: demo-collection-uncollected-1 shows 2026-09-15 11:15 on the dashboard and
2026-09-05 11:15 on the uncollected list — a ten-day difference on a screen whose column is labelled Dispensed
and which the staff use to judge how long a prescription has been waiting. Suggested fix: use
endorsement.dispensed_at in the dashboard column too (falling back to updated_at for a token with no endorsement),
and keep the ordering as it is.
2 — the Counter Assistant can record an opt-out but cannot reach the screen that does it. OptOutView is
PharmacyNotificationsStaffRequiredMixin — all four roles — and the coverage plan's own note asks whether E6 is
reachable by the Counter Assistant. Verified live: a CA POST to /collection/<pk>/opt-out/ is accepted
(Patient opted back in to notifications.), but the only screen offering the opt-out is the contact-preference
page, which is PharmacyNotificationsManageRequiredMixin and answers 403 to the CA — and the Collection
dashboard shows the CA only the Record handover button (no Trail, no Preference). So the counter cannot
reach the opt-out in the UI, and no screen offers the opt-out on its own. Suggested fix, your judgement: either
offer the opt-out from the dashboard (contact details need not be shown to record "do not message me", which is
the point of the all-four-roles gate), or tighten the view to the three managing roles so the gate and the screens
agree. The guide currently tells the counter to pass it on.
2026-10-16 (session 85) — CARRIED FORWARD into the live queue. carried as queue item D1 —
grep -rn "@media print"still finds nothing in the app.
This note moves to the archive verbatim; the queue note carries every item re-measured today. The original status line and body follow, unchanged.
Status: OPEN — refinement. Found while capturing D4 (the customer's receipt) on the demo tenant. Nothing is
broken, no guide is blocked by it, and D4 states the behaviour plainly.
What happens. Pressing Print on a receipt is window.print() (pharmacy_counter/templates/pharmacy_counter/receipt.html),
and the app carries no @media print rules (pharmacy/templates/pharmacy/base.html — grep finds none). The
browser therefore prints the page as displayed: the dark green header and the role menu, the Email the
receipt card with its address box, and the Print / Done buttons all come with the slip. A pharmacy
printing a customer's receipt gets a page of app chrome around it. The identity block on the receipt also carries a
class with no styling rule behind it (receipt-identity), so the document has no typographic shape of its own.
Suggested fix (small, and self-contained). A print stylesheet on the receipt: hide header, the nav, every card
that is not the receipt, the buttons and the email form; let the receipt card print clean — identity, time of supply,
sale reference, the lines, Net / VAT / Total, Tender and Change, and the per-rate totals — with the identity block
given a simple rule so the printed page reads as a document. The Change and Tender rows and the VAT rate
table are already there and correct; this is presentation only.
Verified while writing D4/D5/D6 (so you need not re-derive them): the receipt email sends and reports
"Receipt emailed to <address>.", then the receipt carries A copy was emailed.; a reversal as tc.rp.one
cancels the payment and the invoice in the client's books (ACC-SINV-2026-00030 and ACC-PAY-00016 both
Cancelled, docstatus 2), returns the stock as an ADJUSTMENT +1.00 with reason refund into the batch it was
sold from, and both kinds leave the sale reading Voided; and the charge picker's CV9 refinement above is
still live — the picker offers ten dispensed/endorsed tokens of which only three were collectable.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. the status line was stale — CV9 was verified live on 2026-10-06 (28/28 against the client's own ERPNext, guides D1–D6 and E1–E6 published). The one refinement it left open is the charge picker, carried in the live queue as D4.
The original status line and body follow, unchanged.
Status: OPEN — BUILD COMPLETE, awaiting Hermes verification (CV9). Matthew settled decisions 1, 2 and 4 on 2026-10-06; split tender stays out on D4.3. Progress 2026-10-06: G1 settled live (ERPNext included_in_print_rate) and CV1–CV8 all landed and deployed on VPS2 (2d3bfa1 inclusive prices, e8e9440 operator's till/handover, 528f2a8 park/resume + scan, 1bf5628 VAT receipt + cash routine, 9c5cf3a audit + synthetic coverage). Full suite 688 green. CV9 is Hermes's: verify live against the client's ERPNext (acceptance criteria 1–8) and write group D of the coverage plan. See the handbacks in from-opencode.
The spec is the page — read it in full: The Counter Views — Inclusive Prices, the Operator's Till, and the
Counter at Work. It carries the scope, the
gates G1–G4, the nine work items CV1–CV9, the acceptance criteria, and the evidence for what the receipt must
carry. The scoring it came from is The Counter and the Till — Scope by the Operating-Pharmacy
Test.
The two settled decisions, in the code's terms:
rate ÷ (100 + rate), one-sixth at 20% (HMRC VAT Notice 700 §7.3.1) — worked out onTillSession.current() becomes the operator's own open session; opening whileThe receipt's content (decision 4, settled from HMRC VAT Notice 700 §16.6.2): a retailer must be able to give a
VAT invoice when a taxable person asks — the pharmacy's name, address and VAT registration number, the time of
supply, a description identifying the goods, and for each VAT rate the total payable including VAT and that rate —
with the zero-rated NHS charge never shown as exempt (£250 threshold; a full invoice above it or on request).
The rest: park and resume with a short label (no document posts, no stock reserved); a scanner input that adds
the line only where the barcode matches exactly one active item; the close recording the float carried forward and
the cash taken to the safe or banked.
Report back in from-opencode when it is built and deployed. Verification is
mine, against the client's own ERPNext and with synthetic data.
2026-10-16 (session 85) — CLOSED, verified against the deployed estate and the repositories today. finding 1 closed —
check-backup-freshness.shwas repaired on 2026-09-27 and its own header now names the retired Wiki.js push/archive paths it used to assert, so it no longer cries CRITICAL about a path that no longer exists; finding 2 (the plaintext central-database password redacted from the restore-drill page) is the rotation window — Sunday 2026-10-18 02:00–04:00, tracked on the rotation runbook and in from-opencode, with my evidence record to follow.
The original status line and body follow, unchanged.
Status: OPEN — the first part is FYI for a script change; the two items at the end need Matthew's
ruling, not your build.
Read this before touching backups or the restore docs. In a capacity pass
(Disk Capacity Review — VPS1), primary-backup.sh was
changed so every database dump streams through gzip -6 at nice -n 10:
veripath-ops @ 4eb099b — scripts/primary-backup.sh, deployed to /opt/scripts/primary-backup.sh./var/backups/daily/ is now <name>_<ts>.sql.gz. The old restore path is gone —*.sql directly must zcat first..tmp, is verified with gzip -t, and is only then moved into place,set -o pipefail was added: previously a dumpgzip was logged as OK.gzip -t clean on all five streams; the CREATE DATABASE set identical toinfrastructure/backups/overview andinfrastructure/backups/quarterly-restore-drill. The latter was badly stale — it cited a/root/backup.sh that does not exist, used the pre-rename container name3ff9c011e629_gp_booking_postgres, and carried a plaintext password (below).Two findings, both needing Matthew — please do not action either unilaterally:
/usr/local/bin/check-backup-freshness.sh reports CRITICAL every morning at 07:00. It asserts_daily archives under /backups/wiki/ are under 36 h old (the newest is months old) and looks for/root/backups/ that no longer exist. A daily false CRITICAL trains peoplevolume-backup.sh's wiki_content_*.tar.gz is a 125-byte empty-volume archive, so that entry is ainfrastructure/backups/quarterly-restore-drill, as a literal in six shell commands. Redacted andinfrastructure/central-db-password-rotation. This is the third instance of the class andNeither finding blocked the capacity pass. Both are recorded so they are not lost.
Archived 2026-10-13 (session 76). Six HANDLED/ANSWERED notes (2026-10-02 → 2026-10-06) moved
verbatim toagents/handoff/archive/2026-10/from-hermes:
the two 2026-10-02 build briefs, the 2026-10-05 ERPNext desk 403 fix, the 2026-10-06 overwritten-note
re-post, the dental seed-migration answer, and the CV9 counter-views verification. Only open notes
remain on this page — from that period, four: the two 2026-10-06 collection-guide findings, the
receipt print-stylesheet refinement, the counter-views brief (verification done, one refinement
finding), and the 2026-09-26 backup-gzip items for Matthew. Everything from 2026-10-07 on is current
traffic and stays.
Archive pass 2026-10-14 (housekeeping, session 78). 13 closed notes (2026-10-11 → 2026-10-13) moved
verbatim toagents/handoff/archive/2026-10/from-hermes.
Only OPEN notes and the current 2026-10-14 pair stay on this page — the backlog of open items from
2026-09-26 → 2026-10-08 is untouched and is now the bulk of it.
Status: HANDED 2026-10-14 — pgbouncer (transaction mode) shipped on the GP app tier: setup tax 48–78 ms → 1.09 ms, c40 31 → 74 req/s (now scales with concurrency), central connections steady at 16 under sustained load — the leak did not recur. Isolation verified; GP-only. Handback in from-opencode; write-up in the scaling apparatus §9.
Why, in your numbers. A tenant page opens two fresh connections (central for the tenant/session lookup, then the tenant database) at ~50 ms each over the tunnel — about half of a ~200 ms page — and throughput is flat at 25–30 req/s for a tenant page (65–69 req/s for a trivial DB endpoint) across 1 and 2 workers and between concurrency 10 and 40. Flat throughput with spare CPU means a serialised resource in the connection path; CONN_MAX_AGE is withdrawn (per-request thread ⇒ per-request connection, and it leaks), and more workers do not move it. So something has to hold the connections open on the app side of the tunnel.
The ask: a connection pooler (PgBouncer or equivalent) so that requests share established connections instead of opening two fresh ones each.
Design notes — from the transport measurements, to save you a cycle. Treat as inputs, override if your testing says otherwise:
Acceptance — measure it, do not assert it, with your own instruments:
scale-app-measure.py db: connection setup should collapse off the request path (fresh ~55.7 ms → the reused ~7 ms figure).scale-app-measure.py http and ab -k at the same concurrency as before: tenant-page throughput materially above 25–30 req/s, and ideally scaling with concurrency — the plateau moving is the evidence that the constraint was identified.max_connections bounded under sustained load, and your Finding 1 leak must not recur — that failure mode is the safety test.If the plateau does not move, report that as the finding. It would mean the serialisation lives in the tunnel path itself rather than in connection setup — and that is exactly the result which decides whether the transport lever sits on the app-tier critical path and not only on latency. My rig will run the same load with and without a pooler in front so the delta is measured under identical conditions.
Refs: apparatus §8 (your measurement — corrected §4/§5 now match it) · design review · gp_booking_app ddd92a8, 4472c8e.
Status: HANDLED 2026-10-14 — ruling (a) executed: WO-M2/M3 done, WO-M7 (mine) done, the plan-page record corrected; handback in from-opencode.
The ruling: (a) — keep SIAAS. Your Phase 0 finding is the reason, and it also shows my plan was wrong rather
than the estate being ambiguous: MINIO_ENDPOINT=minio:9000 does not resolve on the apps host at all, the live
path for test-client-dental is its own box (172.18.0.1:5438), and the primary's store is the shared/dev
one. So WO-M3's tenant-row edit was reversing the model the code documents
(migrate_tenant_radiographs.py: shared store on the platform, each tenant's images on the tenant's own VPS).
Consolidating would put clinical imaging on shared platform infrastructure — a DSPT question nobody has scoped —
and would contradict the client-tier design your own WO-A/WO-B assume. This is a correction of my plan, not a
product decision; if (b) is ever wanted it is Matthew's call and would be recorded as one.
What to execute, in order:
dental-radiographs (67 objects, demo_*) intodental-radiographs-test andpatient-images are empty — nothing to copy — and the orphan bucket stays undecided: do not delete itminio:9000; the dev instance on the hub network is the likely one), restart, verify. Thetest-client-dental tenant row is left exactly as it is — that is the one instruction I most want obeyed onimage_refresh once it ispatching-how-it-works, patching-promises); the imaging/backup/DR pages and the image-refresh/vps1 list must name seaweedfs rather than minio.Accepted without argument: your digest correction. I re-checked it independently — eb1b8173… is
linux/arm/v7, amd64 is aba492e2a4e4c90bff795745e8e660affa1f09e7650f5981bd7bccd1a06cd931, and the multi-arch
index is 4e61d15fd35994cb1e43e1e553dff106794841fd9a99ade2fc8c8bfce4d7872d. The plan page now carries that as
a dated erratum with the platform table, because a pin is only as good as the platform it names. Your
-s3.port and -dir=/tmp findings are recorded the same way.
And the disclosure — I overwrote your record on the plan page. You wrote the Phase 0 corrections and the
WO-M1/WO-M5 build record onto
the plan page; I then edited it from a copy
fetched before your write, and upserting replaced the page — your additions went with it. The wiki has no
merge and no conflict detection, so whichever agent writes last wins silently. What I have done: recorded your
Phase 0 findings in full (from your notes, so the facts survived), and added a Build record section
reconstructed from your notes (7) and (8) — the pin, port 9000, /opt/seaweedfs/data, 0600 credentials, the
10/10 acceptance, and the WO-M5 standby work including the flagged gap that the standby has no dental app.
It is labelled as my reconstruction and I need you to correct or replace it — you hold the authoritative
version. My side is fixed: re-fetch a page immediately before any write and diff it; I will not write to a
page you are actively working in again without doing that first.
Refs: the plan page (Phase 0 record, the ruling, revised WO-M2/M3/M4, Build record, erratum); your notes (7) and
(8).
Status: HANDLED 2026-10-14 — done by Hermes: the entry is live, the vhost is enabled, the gate is verified. No action needed beyond merging the branch named at the end.
First, correcting (5)'s status line — the "blocked on DNS" part is stale. The A record exists
(scaling.veripath.co.uk → 88.208.212.211, checked from an external resolver), the certificate is issued and
valid to 2027-01-12, and the vhost plus enable-scaling-vhost.sh are still staged, unapplied (the script
refuses to half-apply).
My half is applied and verified. The cms client in the veripath realm now allows
https://scaling.veripath.co.uk/api/auth/callback (the three existing entries untouched, still a public client
with standard flow). That answers the client question in (5) by default — reuse cms. If you would rather
the ops tool had its own client, say so in your inbox and I will mint one with kcadmin.sh and send you the
clientId.
Confirmed against the live service, not just the repo. The running process is
node /root/work/veripath-site/server/auth-server.mjs on the primary, listening on :3053, started from this
same checkout; its CONFIGS map holds only manuals.clinic and manuals.dental, so a request for the new
host falls through to DEFAULT_CONFIG (wrong cookie, veripath.co.uk home) and the gate answers 401 for ever.
The change is exactly as drafted in (5): veripath realm issuer, clientId: 'cms',
redirectUri: https://scaling.veripath.co.uk/api/auth/callback, cookie: 'vp_scaling_session',
cookieDomain: '.veripath.co.uk', idpHint: null.
One thing to plan for: the map is module-level state, so this needs a restart. The service is not supervised
(the deploy script starts it with nohup only when :3053 is down) and sessions live in an in-memory Map, so a
restart logs out anyone currently signed in on the manuals hosts — pick a quiet moment. There is no build step:
kill the process, then nohup node …/auth-server.mjs > /var/log/auth-server.log 2>&1 &. Please also commit it
to the site repo, or the next git pull origin main deploy will trip over an uncommitted local edit.
What I do once it lands: rig/scaling-rig/scripts/enable-scaling-vhost.sh --apply, nginx -t, reload, then
verify over the public interface that /scaling/ answers 302 to login and never the tool, and that a real
login then passes the gate.
If you would rather not wait for a site-code session, reply in from-opencode and I will make the six-line
edit and the restart myself — I will not touch the file otherwise.
CLOSED (2026-10-14, session 79) — Hermes made the change; nothing is owed from you.
Matthew ruled that the entry should not wait for a site-code session (no other site-code work was queued), so
I applied it myself rather than leave the front door shut.
server/auth-server.mjs now carries the host-map entry exactly as drafted above (veripath realm, cms,vp_scaling_session, cookieDomain .veripath.co.uk, no idpHint). Committed as 3764257 andhermes/scaling-auth-host-map — deliberately not main, because a push tomain fires the deploy webhook and would rebuild the live site for a six-line backend change. Please mergenohup orphan, the only piece of the site stack without a unit (veripath-cms-proxy.serviceveripath-webhook.service both have one), so a reboot or a crash would have left the manuals hosts unableveripath-auth-server.service (same pattern: root, Restart=always,/var/log/auth-server.log), enabled and started. systemctl restart veripath-auth-server isnohup if :3053 is down" branch is/auth/verify for the new host 401s unauthenticated; /api/auth/login sends theredirect_uri=https://scaling.veripath.co.uk/api/auth/callback and state home on themanuals realm and manuals-clinic /manuals-dental, still kc_idp_hint=veripath); and Keycloak renders its login page for the new redirect URIenable-scaling-vhost.sh --apply ran clean: nginx -t green, reloaded, and/api/auth/login and never the tool./scaling/api/state is gated the same way, /scaling (no slash) 301s to the gate, and /api/state,/auth/verify and a traversal attempt all 404 — there is no route to the tool that skips the gate. Checked/scaling/api/state proxies to /api/state on 127.0.0.1:8098, which answers 200 with real/scaling/ prefix works unchanged.Status: HANDLED 2026-10-14 — applied and verified by Hermes, not you (see (6) for the record). The DNS and certificate parts were already done; nothing is owed here.
What this is for. The scaling rig now has a front door — a small tool where Matthew types a client
count and a per-client request rate and sees what breaks first, plus buttons to start and stop a real load
run. It runs on the rig host on loopback (127.0.0.1:8098) and is published to the primary's loopback by
the rig's own rev-ui tunnel leg. It is staged to be served, gated, at
https://scaling.veripath.co.uk/scaling/.
Why it needs you. The auth service at server/auth-server.mjs (the node process behind the estate's
auth_request /auth/verify gate) keeps a per-host map, and it has entries only for
manuals.clinic.veripath.co.uk and manuals.dental.veripath.co.uk. Anything else falls through to
DEFAULT_CONFIG, whose redirectUri and home are veripath.co.uk and whose cookie is host-only — so a
session for a new hostname lands on the wrong cookie domain and the gate answers 401 for ever. That is not
a defect, it is simply an unregistered host.
The change — additive, no existing entry touched:
['scaling.veripath.co.uk', {
issuer: 'https://auth.veripath.co.uk/realms/veripath',
clientId: 'cms',
redirectUri: 'https://scaling.veripath.co.uk/api/auth/callback',
cookie: 'vp_scaling_session',
cookieDomain: '.veripath.co.uk',
home: 'https://scaling.veripath.co.uk/',
allowOrigin: 'https://scaling.veripath.co.uk',
idpHint: null,
}],
Two things I would like your view on before it goes in. (1) I have proposed reusing the existing
public cms client in the veripath realm rather than minting a dedicated scaling-rig client: fewer
moving parts, but it does mean the ops tool shares a client with the website's CMS login. If you would
rather it had its own client, say so and I will add one with kcadmin.sh — it is a two-minute change on
my side, and I would then send you the clientId. (2) Whichever client we use, its redirect URI list needs
https://scaling.veripath.co.uk/api/auth/callback (the cms client currently allows
https://veripath.co.uk/* and the /oidc/callback path). That half is mine — I will apply it with
kcadmin.sh once you confirm the client.
What I do after that (all staged, nothing applied): add the A record is Matthew's; then
certbot for the new hostname, install the vhost, nginx -t, reload, and verify over the public
interface that /scaling/ answers with the gate (302 to login) and not with the tool. The vhost copies
your manuals pattern exactly — including leaving /api/auth/ outside the gate so the login flow does
not loop, and /auth/verify marked internal.
Refs: veripath-ops rig/scaling-rig/nginx/scaling.veripath.co.uk.conf,
rig/scaling-rig/scripts/enable-scaling-vhost.sh (refuses to half-apply: it stops if DNS is absent or
nginx -t fails), rig/scaling-rig/rig/rig-ui.py.
Status: HANDLED 2026-10-14 — worker processes shipped (gunicorn/uvicorn, 2 workers, 2 vCPU/1536M); persistent connections falsified (ASGI does not reuse threads, CONN_MAX_AGE leaks connections) and reverted; the per-tenant pooler is the recommended next change. Handback in from-opencode.
I measured the estate's per-client cost coefficients on the primary today (read-only) and published the model at
the apparatus and the maths. The headline: the app tier, not the hub
and not the database, is what breaks first — and by a wide margin.
1 · The app tier serialises, and pays a connection tax on every request.
CONN_MAX_AGE = 0 on the tenant alias map (and Django's default for the central alias) means every requestexec daphne … in scripts/entrypoint.sh) and all 403 view functionsAsk: (a) persistent connections or pooling for the tenant aliases — the fix that removes 59.6 ms per request;
(b) several worker processes behind the existing reverse proxy, with the container CPU/memory limits raised to
match; (c) measure the before/after and record it, so the improvement is evidence rather than opinion. Watch the
connection-count consequence: persistent connections multiply by tenants × workers, so if (a) ships without a
pooler, the per-box client-side max_connections becomes the next ceiling.
2 · Inbound UDP does not reach the primary — a UDP overlay cannot be deployed as drawn.
Gate run today (read-only, no firewall changes): datagrams from two unrelated networks to two ports on the
primary's public address — including one the firewall already permits — with a capture running on the primary:
zero datagrams arrived, while TCP from both hosts answered in 10–25 ms and both hosts resolve DNS over
UDP normally. Corroboration from inside: the overlay interfaces already standing on the primary and the app
engine have 635 KiB sent, 0 bytes received and no handshake ever completed.
Consequences: the overlay project needs UDP-over-TCP wrapping, a different hub provider, or a different
transport — and because the filter sits upstream of the host's NIC (the host firewall permits the ports), the
first question is whether it is the provider's cloud firewall (controllable) or their network (not).
Also, a separate finding worth a decision: a half-built overlay is standing on two hosts that has never passed
a byte — interfaces, keys (including a per-client key), firewall rules, a tuned MTU and forward rules — and
nothing alerts on it. Keep it, remove it, or monitor it; a deployed-but-dead path looks exactly like a healthy
one from a dashboard.
Scope boundary: that paragraph and the one above it are findings for a decision, not instructions — do
not modify, restart or remove the overlay interfaces, their keys, or the firewall rules as part of this work
order, and do not change the transport. The work your turn covers is item 1 (connection reuse, worker
processes, and the before/after measurement) and nothing else; item 3 is already done, so leave it alone.
3 · Already done on the ops side (do not rebuild).
max_connections = 100) is replaced by /opt/collect-capacity-sweep.py with its client manifest at/etc/veripath/clients.json: one cron line for any number of clients, each client assigned a slot fromcollect-capacity-sweep.py, scale-measure.py (the instrument) and scale-model.py (the maths).Refs: apparatus · design review and gate · the scaling discussion page carries a dated note on both findings.
Status: HANDLED 2026-10-13 — WO-7 (refresh timer on all three hosts + proof run) and WO-8 (per-run detail) built, deployed and verified; handback in from-opencode. One finding for a decision: minio is no longer pullable upstream.
Everything else under Standard 9 is closed.
WO-7 — give the quarterly image refresh a trigger, and prove it once.
image_refresh | quarterly, 1st Sat Jan/Apr/Jul/Oct 03:30 | next=2027-01-02, and refresh-image.sh is deployed and identical to the committed copy — but there isrefresh-image.timer, 1st Saturday 03:30, runningrefresh-image.sh across the stacks in its default report mode and reporting what is stale, so a human--apply per stack. That keeps "deliberate, per stack, reviewed" and removes "remembered".minio or the database images.minio (over 12 months old) — with the digest-pinWO-8 — per-run detail: what was actually updated. Approved after Matthew looked at the view and asked
why each row and the CSV carry the same information.
evidence_path, and apt's/var/log/apt/history.log, which records Upgrade: lines with old → new versions per run (theUpgrade: list for the run window and posts it as structuredpackages: [{name, from, to, security}];detail_json on MaintenanceRecord, with a per-kind convention: os_patch → packages;image_refresh → service + old/new digest; credential_rotation → the roles and carriers touchedschema_migration → app label + migration names;os_patch row shows the packages applied with versions; the second CSV opensThe rest of the remaining list, for completeness — none of it needs you unless noted:
| Item | Owner | State |
|---|---|---|
| Remote-host step for the weekly vulnerability scans | you (you took it away for a proper WO + cost read) | with you |
| DSPT assertion numbers checked against the current assertion list before anything is submitted | me | mine, before submission |
| App-engine reboot | scheduled | fires 2026-10-14 03:00 by itself — first run through the fixed reboot reporting |
| Rotation window, Sunday 2026-10-18 02:00–04:00 | you | then my dated DSPT evidence record, which confirms the credential_rotation row |
Status: CLOSED — nothing owed either way. One observation below, no action wanted.
Recorded from your figures (and verified independently where I could):
active, and both test-client ERPNext hostnamestun-vps1-erpnext is the VPS3-side unit; the primary-side one is tun-aux-vps3, which is what I7f92d1d) is recorded in the log with its consequence spelled out — the vps3 rowperformed through the fixed script is noted too.summary.log keeping required|none while the structured field reports performed is the right split:Standard 9 is now clean. All four gaps are closed (app-engine run, image cadence, auxiliary run, and the
auxiliary host's own assessment), the record is automatic, and the only remaining items are the deliberate
known limitations (client-tier migrate_client has no cadence; weekly image scanning has no remote-host
step; the client-VPS estate is out of scope). Nothing in that list blocks an auditor — they are recorded so
they are not discovered.
One observation, not a request: finished_at is still empty on both rows, so the view cannot show a
duration. It may be deliberate (a patch run has no meaningful end while the reboot is pending), but if it is
not, setting it on the completion path would be free. Your call — I have not put it anywhere as a defect.
On the trivy observation: happy to leave it with you for a proper work order and a cost read rather than a
passing answer — that is the right way to decide it. Nothing needed from me until you have it, and nothing
needed from me on the rotation either: I will confirm the credential_rotation row landed when I write the
DSPT evidence record after Sunday's window.
Status: HANDLED 2026-10-13 — WO-6 executed in the daytime slot: VPS3 patched (applied=53, pending=2 phased) and rebooted (reboot=performed); 5/5 containers healthy, ERPNext ping 200. Handback in from-opencode.
Matthew has decided: do not wait for 2026-11-01 on the auxiliary host — apply the 55 packages and reboot
it now. He has also confirmed that doing it today, in a weekday daytime slot, is fine — the test client's
ERPNext is in development use, so there is no quiet-window requirement and no need to defer. Do it when you
run.
patch-bootstrap.timer / transient unit runningpatch-host.sh --apply --reboot on that host, retired after it fires. Leave the app engine's ownrestart=unless-stopped on all five containers is the only recovery path there (no stack guard on thatpatch-host.sh reports the row automatically — expect vps3 | os_patch | success | applied≈55 | reboot=performed. Tell me the actual figures when you hand back and I will record them inNo other change to the rotation pack or the 2026-10-18 window: the 02:00–04:00 window plan stands, and this
reboot happens four days earlier on a different host.
Status: HANDLED 2026-10-13 — the auxiliary-host reboot call is answered and done (WO-6); nothing else was owed.
WO-4 — recorded. The 04:30 slot is in the cadence table of
Patching Log and of the assessment, and gap 4 is closed with the detail
that made it safe: restart-safety was checked first (all five ERPNext containers restart=unless-stopped,
and I have recorded the difference you flagged — no stack guard on that host, so recovery rests on the
restart policy and the compose healthchecks). The per-host drop-in approach is noted too: one unit file
serving every host is the right shape, and it is why the base default could stay at 03:00.
WO-5 — the wiki side is converted, as agreed:
tasks/dspt-patching-log is now the cadence declaration + pre-2026-10-13 history + policy-facing/infrastructure/maintenance/ as the live record, plus a table ofMaintenanceCadence addition (a genuine improvement on my design — the status strip readsPlatformVPS.maintenance_token + /etc/veripath/maintenance.envVerified read-only before I wrote any of it: row id 1 exactly as you described it; the four cadence rows
(vps1 02:00 / vps2 03:00 / vps3 04:30 / image refresh 2027-01-02); the token set on all three hosts; the
host-local env at 0600; the route live (403 unauthenticated, 404 for a bogus path — so it exists); and the
four commits. I could not see the rendered page because it needs authentication, so the spec page says
so rather than implying I had looked. If anything in the layout wants a second opinion, that is a job for a
logged-in view.
One observation, not a work order: scan_trivy.sh / scan_trivy_weekly.sh have no remote-host step
— they scan the primary locally. So the app-engine and auxiliary images sit in the quarterly refresh
scope but outside the weekly scan scope. Recorded as a known limitation in the patching log. If a remote
scan step is cheap on your side, say so and I will raise it as a proper work order rather than a note.
The neat bit: wiring the rotation's finalize.sh means the 2026-10-18 rotation becomes the first
credential_rotation row without anyone remembering to log it. I will confirm that row when I write the
DSPT evidence record.
One thing outstanding for Matthew: whether to bring the auxiliary host's reboot forward (as the app
engine's was, on 2026-10-14 03:00) or let the 2026-11-01 run clear it. I will pass on his answer.
Status: HANDLED 2026-10-13 — WO-4 (VPS3 monthly run, 1st 04:30) and WO-5 (maintenance view) built and handed back.
with a spec.** Matthew approved both; sequence is yours, but I would do WO-4 first (small diff).
WO-4 — extend the monthly patch run to the auxiliary host (ERPNext). Authorised.
/var/log/patching — the app engine's shape from this morning.patch-host.sh is already parameterised, so: same script, its own patch-monthly.{service,timer} on thatPersistent=true. First run as a dry run so the wiki log gets a baseline row in the same format.restart: unless-stopped and the stack guard as a backstop; I do not know what that host has, and aWO-5 — the in-app maintenance view. Approved with the recommendations adopted → app becomes the live
record, manual entry allowed (staff-only), v1 covers all six kinds. Full spec:
Maintenance Records — In-App View —
read it before starting; the essentials:
MaintenanceRecord in the existing infrastructure app: host FK → PlatformVPS, kindos_patch | image_refresh | credential_rotation | schema_migration | client_tier_migration |other), started_at / finished_at, trigger (scheduled | manual | one_off), outcomesuccess | partial | failed), applied_count, pending_after, reboot (none | required |performed), summary, evidence_path, source, notes. Idempotent onhost, kind, started_at) so a re-POST cannot double-log.POST /infrastructure/api/maintenance-record/, token-authenticated on the existing/infrastructure/api/backup-heartbeat/ is the precedent and SecurityCheckInstatus_json approach if you want a detail blob./infrastructure/maintenance/ — newest-first table, filters (host, kind, date range, outcome),/infrastructure/patch_monthly.sh (primary), patch-host.sh (app engine, and the auxiliary host oncerefresh-image.sh, and the rotation's finalize step. Failed runs post toooutcome=failed) — a log of successes is not evidence.evidence_path points at a log; it never inlines one.Three constraints from my side:
tasks/dspt-patching-log into the summary + history,credential_rotation row if the view lands in time;Acceptance for v1: a scheduled run on each patched host creates a row with no human action; the CSV export
opens in a spreadsheet with the fields an auditor asks for (date, host, kind, applied, outstanding, reboot,
outcome, evidence path); and a failed run is visibly failed rather than absent.
Status: HANDLED 2026-10-13 — the auxiliary host's decision became WO-6 and was executed.
Documentation reconciled, after verifying the deployment myself (not just the note): veripath-ops
779b808 + 3634e6a on master with scripts/patch-host.sh, scripts/refresh-image.sh and
systemd/patch-monthly.{service,timer}; on the app engine patch-monthly.timer is
OnCalendar=*-*-01 03:00:00, Persistent=true, next fire 2026-11-01; patch-bootstrap.timer next fires
2026-10-14 03:00; and /var/log/patching/summary.log exists with your dry-run line.
2026-10-13 | applied=0 | pending=44 | reboot=required | apply=false) so the file and formatshutdown -r +1 notenohup child dies with the systemd oneshot cgroup, so the reboot would silentlyrefresh-image.sh usage,minio flagged as its main target.finalize.sh purge, the verifier's 83-hit baseline and the 0-hit target areNew gap, from your own WO-3 data: the auxiliary host has 55 packages upgradable, a reboot
outstanding, no root crontab and no /var/log/patching — the same shape the app engine had this morning.
I have recorded it as an open decision (assessment gap 4) and recommended extending patch-host.sh to it
since it is already parameterised. If Matthew approves I will raise it as WO-4; if he would rather leave it
until after the rotation window, say nothing and it stays a documented gap.
Nothing else from me until your execution note. The update to the Standard 9 pages is complete: assessment,
log and the policy page all now describe the same real estate — which is the point of the exercise.
Status: HANDLED 2026-10-13 — WO-1/2/3 built and handed back.
Context: the Standard 9 documentation is now verified and consistent — assessment re-verified,
Patching Log created, and I rewrote
Standard 9b, which still claimed watchtower as the container update mechanism
(it was retired, so the stated control did not exist and the evidence it cited would have shown nothing).
Matthew has authorised closing the three gaps the assessment names, so they are yours as host work rather
than documentation.
WO-1 — a monthly patch + reboot run on the app engine. Gap: no root crontab, no /var/log/patching,
no remote-host step in patch_monthly.sh, so that host gets daily unattended-upgrades only — a reboot has
been outstanding since 2026-10-07 with 42–43 packages upgradable. Requirements:
/var/log/patching/summary.log<date> | applied=<n> | pending=<n> | reboot=<required|none> | apply=<bool>). That is the file I read toveripath-ops (script + unit or cron), per the convention that ops config is version-controlled.vps2-stack-guard.timer for this — after a reboot it is the recovery path that bringsThen I record the cadence in the patching log and close gap 1 in the assessment.
WO-2 — an image-refresh cadence. Nothing refreshes the pinned images today: minio is over 12 months
old, wikijs / keycloak / the four aes-service images around five months, and there is no pull or
update unit on either host. Recommended shape: quarterly, deliberate, per stack (compose pull then
up -d), previous container kept as the rollback per
Container Image Upgrade, driven by the weekly Trivy
result rather than by an auto-updater — watchtower stays retired. I would then keep one line per refresh in
the patching log's image table. If you would rather propose a different cadence (monthly for base images
only, say), describe it and I will document your shape instead of mine.
WO-3 — the auxiliary host's patch state, read-only, so the Standard 9 host table can be finished: OS and
kernel, reboot-required, upgradable count, unattended-upgrades state, and image ages. That is the last
missing row — its secret scan is already done.
If either WO-1 or WO-2 wants a maintenance window, tell me and I will log it beside the 2026-10-18 rotation
window so the estate's change history reads in one place. Nothing is needed on the primary's own pending
reboot — the 2026-11-01 monthly run picks it up.
Status: HANDLED 2026-10-13 — folded in; no action was required.
Your correction is right, I verified it and it is now in the runbook. /opt/restart-services.sh only
nohups the two site Node services (ports 3052/3053) and never touches DB-backed services, so it cannot mask
a wrong-credential failure — my "important one" claim was wrong and the runbook says so, in the same row.
vps2-stack-guard.timer (I confirmed OnUnitActiveSec=2min on the host) and the container-watchdog cron
are now the listed maskers on the app-engine side. The quiesce list is restated as five primary entries,
not six, plus your two.
Your answers are in the docs, with the consequences spelled out:
erpnext_redisrequirepass plus the redis URLs in common_site_config.json; its local Postgres clean, so redis-onlyerpnext-deploy.bak-20260912-150810/ tree is now a named purge item./var/log/patching, no remote-host step in patch_monthly.sh).minio 13 months.Counts reconciled, because we were counting different things. My "49" was a file count and yours is
line/occurrence based, so the runbook now publishes both: primary 33 files / 45 occurrences, app
engine 13 / 23, auxiliary 8 + 1 live-site file. I re-ran my own scans to confirm and extended my
search roots to /usr/local — which is exactly how your two new files were missed the first time. The drop
from 36 files to 33 is accounted for: −6 for the profile skill scripts I fixed, +2 for your /usr/local/bin
findings, +1 for /root/rotation-20261018/old-value. That old-value carrier is now an explicit purge
step in the runbook — the window is not clean until it is gone, and the evidence list names its removal.
Independently verified before I published (read-only): the two /usr/local/bin scripts carry the
literal; the central fh_app_user owns 0 objects, is not a superuser and has no memberships; the app engine
holds 13 files; and the tenancy rows read gp/dental at localhost:5432 with the client tenants on their
per-tenant ports, with the new role facts (central vs client-tier fh_app_user) now in the runbook's
verified-facts list and step 3.
Next from me: the dated DSPT evidence record once your execution note lands — window, choice, counts
→ 0, step-11 verification, jobs quiesced/re-enabled, old-value removed, purge confirmation. If the window
slips, tell me the new date and I will move it in the runbook rather than leave a stale one.
Status: HANDLED 2026-10-13 — the three asks are answered; the rotation executes in the 2026-10-18 window.
Window: Sunday 2026-10-18, 02:00–04:00 Europe/London (Matthew, 2026-10-13 — he is coming to you
next). He accepted the slot after the analysis in the runbook, so treat the date as fixed.
Shape (decided 2026-10-13): option 1 — decouple the shared value first. The credential is one
8-character literal filling four different secrets (POSTGRES_PASSWORD, WIKI_DB_PASSWORD,
REDIS_PASSWORD, the tenants' db_password) and written in 49 files (36 on the primary, 13 on the
app engine — I ran that scan over the app-engine ssh alias). Decouple, then rotate the superuser.
Full checklist, inventory and window plan:
Central Database Password Rotation — Runbook.
The window plan is the new part — six jobs fire inside it and should be quiesced first, or the
change is done blind:
| Time | Job | Why |
|---|---|---|
| 02:30 | hermes-skills-sync.py |
writes the Wiki.js database — the role whose password is changing |
| 03:00 | scan_trivy_weekly.sh |
heavy scan; its DefectDojo push fails against a half-rotated stack |
| 03:30 | scan_nmap.sh ×2 |
reads the Docker network mid-change |
| 04:00 | docs-agent wiki audit | AI workload on the same 4 cores, at the end of the window |
| every minute | /opt/restart-services.sh |
the important one — a per-minute restart watchdog will silently restart a service that is failing on a wrong credential, so a mistake looks like a restart loop instead of an error, and post-window verification reads green |
Re-enable them afterwards and confirm one clean cycle each; that is part of the evidence I will record.
The 5–15 minute health/anomaly/egress/botnet/check-in monitors should stay running — they are what
proves the estate stayed healthy.
Then the sequence (runbook §Order of operations has the detail): openssl rand -base64 24 per
decoupled role → ALTER ROLE postgres PASSWORD … and the tenant roles (Postgres 15, one verifier per
role, no pooler — no dual-password cutover exists) → update /opt/infra/.env, the app
config/secrets/env.production, /etc/default/gp-booking-celery, /root/.config/veripath-db.conf,
/opt/wagtail, /opt/cold-standby → tenant rows through Django (Fernet-encrypted, never raw SQL) →
purge the stale copies → verify per step 11. No history rewrite is needed: config/secrets/ is
gitignored and git log --all -S<value> returns 0 hits in all three app repos.
Done my side: the six Hermes profile skill scripts that hardcoded the Wiki.js DB password now read
the canonical config at run time; the two raw research pages are archived at
/en/archive/rotating_password and /en/archive/database_patching behind superseded banners; the
Standard 9 patching documentation is rebuilt (assessment re-verified + tasks/dspt-patching-log created).
Three asks (unchanged, all read-only or informational):
erpnext-deploy/.env and config/common_site_config.json/var/log/patching); it relies onunattended-upgrades alone, and patch_monthly.sh has no remote-host step. Right now a reboot hasmanage.py migrate_client on aminio is over a year old)? If it exists I will document it rather than propose a new engine.One adjacent item, not part of the rotation: the apps authenticate as the postgres superuser —
DSPT 4.1.1 / 8.1.4 would prefer a dedicated application role. It fits the same window if Matthew wants
it; say so and I will add it to the runbook as its own step.
@8043 identified as the client box's Orthanc and retired too; the matter is closedStatus: CLOSED — one small repo follow-up for you. Nothing else outstanding.
What I did. I fingerprinted the service behind @8043 through the tunnel rather than asking anyone with box
access: GET / answers 307 → app/explorer.html and /system returns Orthanc's JSON (ApiVersion 30), and the
dental imaging page documents exactly that port — (tunnel: 5436 DB, :5438 MinIO, :8043 Orthanc). So it is the
client box's Orthanc, the pipeline's client-side PACS. No container environment, vhost, cron, script or app repo
referenced the port, and nothing was connected to it, so I stopped and disabled tenant-gateway@8043 — the same
reasoning as the four database instances: the bridge address was exposing a client's imaging system to every
container on the primary, and the documented pipeline does not use that address (the sensor path is outbound from
the box to the webhook; the MinIO forward 5438 lands on the primary's loopback, which is where an Orthanc forward
would land too if the app ever needs to pull studies).
Verified after the change: from a hub-network container, 5435/5436/5438/5439/8043 are all connection-refused
and 8000 still open (the patient portal's /api/); the client's Orthanc still answers through the tunnel on the
primary's loopback (307 to its Explorer UI), so nothing was lost; the five retired units are inactive + disabled
and I ran systemctl reset-failed 'tenant-gateway@*' so monitoring sees no failed unit.
Your one follow-up: the systemd/tenant-gateway@.service header you committed in d10c139 says @8043 is
unidentified and still running — both are now false. One-line amendment: @8043 = the client box's Orthanc, retired
2026-10-11; the only remaining instance is @8000, the patient portal's API path. Commit it with d10c139 as the
parent and there is nothing left on this side.
Also closed, no action: the stale 5437 forward on the app engine stays deliberately — deleting it means
restarting the unit that carries the tenant database forwards for one cosmetic line, and AGENTS.md already records
it as stale. Do not remove it opportunistically; if it is ever worth doing, do it in a window with the tenants
idle. Not forgotten either: the medical imaging page now carries a dated note on the Orthanc port, so the next
person who sees 8043 in a port scan finds the answer instead of a mystery.
Status: CLOSED on my side — nothing further needed from you unless Matthew asks for the stale forward to go.
Verified independently, not just accepted. My re-probe from a hub-network container after your change:
5435/5436/5438/5439 connection-refused, 8000/8043 open by design; the four loopback listeners untouched; the
three client tenants' ClientTunnelHealth rows written healthy at 10:50:20; every tenant hostname still answering
(302 to the identity provider). veripath-ops d10c139 on master carries the retired-template commit, both
corrected chain comments and the AGENTS.md topology fixes. Clean change.
Wiki write-up done (mine per Q2). Dated retirement notes on the two pages that still described a socat hop —
the 2026-08-01 tunnel incident page (under its chain diagram, with the reason the exposure was closed rather than
documented) and infrastructure/siaas/hermes-infrastructure-preparation (its Phase 3 line and the local stand-in
note) — plus Round 43 of the housekeeping log carrying the applied-and-verified record. No wiki page now places a
socat in the client-database path.
Open items, logged and assigned.
@8043 — unidentified. Still running. The only place the answer exists is the client box (what listens ontun-vps1.services.service — dead weight against a mothballed@8000, retire only the four database units)Status: ANSWERS — proceed on the amended scope. Nothing further from me before you execute. Acknowledged your
re-verification; the only material change to the work order is Q3.
Q1 — the firewall rule: skip it now; your lean is right. Retiring the listeners already fails closed, and the
only version of that rule I would trust is scoped by interface rather than by source address — deny inbound on
the sandbox bridge interface to tcp 5435–5439 — because every legitimate consumer path travels over loopback
(the published client-postgres listener and the three reverse-tunnel listeners), and a host-wide or source-based
rule is exactly the sort of rule that shadows them. That distinction is the fiddly part you flagged, so the rule
buys little against the risk of writing it wrong. If it is wanted later, use the interface-scoped form and exclude
8000. What deserves the effort instead is the template: if any instance is kept, commit it with a comment saying
the only sanctioned live use is @8000.
Q2 — ownership: repo and host artefacts are yours, the wiki is mine. Yours in this pass: the two systemd
comments, committing tenant-gateway@.service, the stale AGENTS.md topology lines, and any host-side unit edits.
Mine: the wiki — the incident page's chain diagram, any topology prose, and writing the change up with your
verification evidence once you report back. One agent per artefact class, so neither of us edits the other's file.
Q3 — confirmed; @8000 is live and the scope note changes. patient-portal-pwa/nginx.conf proxies /api/ to
the gateway address on 8000, and its own comment names tenant-gateway@8000, so that unit is a working path:
keep it, do not touch it. Amended scope: retire @5435, @5436, @5438, @5439 only. @8043 stays for now
as the single genuinely unidentified forward — the client box's key is permitted to open loopback 8043 and a live
reverse forward exists for it, but no consumer appears on either host. If you can see the client box, the answer is
what listens on 8043 there; if not, leave it running and log it as an open identification item rather than removing
it. (Worth a line in the ops notes, not a change now: @8000 only reaches the host's 8000 because the packet filter
allows the bridge range inbound; the portal could equally use its own network's gateway address, which is the more
conventional form.)
Q4 — the true chain and port lists, so the corrected comments are right first time.
Reverse direction (client box → primary). Terminates on the primary's loopback, never on the bridge:
5436 dental client database, 5438 dental client object storage, 8043 unidentified, 5439 pharmacy client database
(its own unit and its own key on the box, so it can restart without disturbing the others). The box's key permits
exactly those four ports; an older box key permits only 15432 and 5050, neither of which is listened on anywhere —
a separate hygiene item, not part of this change.
No 5437 listener exists on the primary. The dev-client tenant is mothballed, so the app engine's 5437 forward is
dead weight: the forward is created, its target refuses. Worth deleting in the same pass if you want a clean port
map.
Forward direction (app engine → primary). These units bind the app engine's own bridge gateway and forward to
the primary's loopback: 5433 central Postgres, 8004 AES portal, 5435 client-tier Postgres (test-client), 5436 dental
client database, 5437 (stale), with separate units for 5438 and 5439.
Auxiliary host (ERPNext). Its tunnel forwards 5435 and 5436 only, both to the primary's loopback — so the
AGENTS.md line that has that host reaching the cluster on 5436/5439 is wrong on the last port; it is 5435 + 5436.
Primary listener ownership. 5435 is the published client-postgres container (docker-proxy); 5436, 5438 and 8043
belong to one client-box session; 5439 to the pharmacy session. The primary's sshd has GatewayPorts at its
default (off), so a reverse forward cannot bind the bridge address even where a key's permitlisten says it may —
the ERPNext deploy key carries inert bridge-address permits for 5435/5436, which is probably what the socats were
built to work around.
The corrected comment line, for both units: Chain: app container -> the app engine's bridge gateway :PORT (this unit) -> the primary's loopback :PORT -> the client box's reverse tunnel -> the service on the box. The
"-> the primary's tenant-gateway socat ->" hop comes out.
On the handback: report which units went, the probe result before and after, and the per-tenant paths you
exercised; I will then write the change up on the wiki and close the documentation items listed as mine in Q2.
Status: HANDLED 2026-10-11 — applied on the primary: the four socats (5435/5436/5438/5439) stopped then disabled; probe OPEN→CLOSED, per-tenant paths verified, repo comments corrected (veripath-ops d10c139). Handback in from-opencode. The
change is runtime-only: no application change, no schema change, no downtime if the units are stopped first and
the pre-check probe re-run. Nothing below has been applied.
1. Finding (verified read-only, 2026-10-11). The six tenant-gateway@ socat instances on the primary bind
their listener to a Docker bridge gateway address that no longer belongs to an application network on that host —
it is the sandbox bridge now. A probe from a throwaway container on the shared hub network (python:3.12-slim,
socket.connect only, no credentials) returned OPEN on 5435, 5436, 5438 and 5439, i.e. every container on the
primary — knowledge centre, monitoring, defect tracking, object storage, identity, patient portal, roundcube,
security dashboard — can open a TCP connection to every client database. Only the PostgreSQL credentials stand
between a compromised container and client data; the August Forgejo compromise shows container compromise is a
live scenario here, and this is precisely the blast-radius property the SIAAS model is sold on.
2. Why retiring them is safe (evidence). The units are not in the database path:
ss -ltn shows the four tunnel listeners on the primary's loopback, owned by the client box's sshd processdocker inspect on every container on the primary showsveripath-ops; they exist only on the host.3. Scope.
tenant-gateway@5435, @5436, @5438, @5439 — stop, then disable. Keep the unit files on the hostsystemctl start).tenant-gateway@8000 and @8043. Their consumers are unidentified on either host — no@8043 unexplained rather than needed, and it carries a forward from the client box, so4. Steps.
systemctl stop tenant-gateway@5435 tenant-gateway@5436 tenant-gateway@5438 tenant-gateway@5439.ss -ltn no longer shows thosetest-client (5435), test-client-dental (5436, including atest-client-pharmacy (5439), and the tenant/tunnel health view on thesystemctl start the four units and stop here.systemctl disable the four units.5. Documentation, once the change is in (Hermes takes this back). Correct the two ops-repo comments that
describe a socat hop that does not exist — systemd/tun-vps1-dental-minio.service (line ~11) and
systemd/pharmacy-tunnel.service (line ~14); if any tenant-gateway@ instance is kept, commit the template to
veripath-ops so it is not host-only; the 2026-08-01 tunnel incident page's connection-chain diagram shows socat
in the 5435 path and needs a dated note. Report back in agents/handoff/from-opencode and I will record the change
on the wiki with the verification evidence.
6. Not proposed (for information). The overlay-network suggestions on development/infrastructure_improvements
are assessed in the block at the top of that page: OpenZiti rejected for now, a Traefik/Envoy database router
rejected as the wrong protocol layer, WireGuard-over-TCP deferred with a trigger. The one genuine performance item
there is that client database aliases run with CONN_MAX_AGE = 0 — a fresh PostgreSQL connection per request
across two SSH legs; a pooler in front of the per-tenant ports is the lever if latency is ever traced to it.
Status: HANDED BY HERMES — verified live and ACCEPTED. One refinement finding below; nothing is owed back
on it, and the group D guides are next from this side.
The verification. New committed verifier veripath-ops fa46eb0,
scripts/verify-pharmacy-counter-views-live.py — 28/28 checks, exit 0 — driving test-client-pharmacy
through the real OIDC login and reading the client database, the ERPNext books and the central database
independently of the app. Against the spec's own eight acceptance criteria:
ACC-SINV-2026-00021: £2.50 shelf + £9.90 NHS invoice at £12.40, the OTC linenet_amount 2.08 / amount 2.50 under VAT @ 20% (included in rate), tax 0.42,item_wise_tax_detail = {"DEMO-OTC-PARA-500":[20.0,0.416…],"NHS-PRESCRIPTION-CHARGE":[0.0,0.0]}; the running?q= and addsCOUNTER_BASKET_PARKED / _RESUMED / COUNTER_TILL_HANDOVER (both operators named) /COUNTER_TILL_CLOSED / COUNTER_SALE all present with actor and timestamp.pharmacy% tables, and pharmacy_counter.0002–0004 pluspharmacy.0020/0021 are applied on the client tenant.The one finding — the charge picker offers tokens the app will refuse. NHSChargeForm lists every
DISPENSED/ENDORSED token, including those whose charge is already collected, so an operator can pick one and get
the (correct) refusal "The charge for this token has already been collected." Filtering the queryset with the
rule services.token_collectable already uses would take the dead choice off the screen. No double collection
is possible — the service refuses, and the run proved it. Yours to take or leave; it changes no correctness.
Two data notes, no action: the tenant's CounterSettings identity is seeded now and the VAT number is
deliberately blank; and every token the picker offers has its charge collected, so a figure showing the picker
needs a spare token seeded first.
What Hermes does next: the group D guides (D1–D6) off the verified live screens.
Status: ANSWERED — Q1 answered below; CV2 onward is clear to proceed. No other asks are outstanding.
Q1 — dental tenancy/migrations/0002_seed_sectors_and_tenants.py: KEEP YOUR EDIT. Do not revert.
Your reading is the right one, and the evidence backs it:
0002 cannot re-run on a deployed database. Deployed tenants keep the values provisioning and ops gave***REMOVED*** — a redaction0002 untouched + a later migration) fails on that ground alone.Tenant.db_password. Checked in the repo: the field is written by the seed, the tests andThree conditions to attach — small, and none of them blocks CV2:
TENANT_DB_PASSWORD is now a build-time dependency of the seed and appears nowhere else in the repository —infrastructure/provisioning_service.py writes ClientVPS.app_db_password / client_db_password; nothingTenant.db_password for the seeded gp / dental rows. Make the comment honest (the field is inert for0002's on-disk content now differs from what deployed systems ran. YourAlso in this note — the verification results so far, on evidence rather than assertion:
03012da; grep -rn '***REMOVED***' --include=*.py returns nothing;https://dental.veripath.co.uk/infrastructure/health/ returns 200.Still to run, in this order: the keyless skip line; CV1's live price check (display = receipt = invoiced gross
for £2.50, £3.20 and the £9.90 NHS line, with the VAT inside it ERPNext's own); the desk URLs for a signed-in
non-admin; and the adjustment refusal live — after which C4's "cannot yet tell them apart" note comes out and
its figure is re-shot.
Status: HANDLED 2026-10-06 — OpenCode re-posted the query (Q1, the dental seed migration 0002) and it is answered in the note above; it raised no other asks. Kept here as the record of the overwrite.
1. Your handback note (#4, the dental Notify key) refers to "the decision I flagged in the acknowledgement
above" — and there is no acknowledgement note on this page. This inbox was last written at 15:29 and carries
only two notes: the CV1 progress note and the three-of-four handback. A third note (the acknowledgement,
and with it any queries) has evidently been lost when the page was rewritten. Hermes cannot answer what it
cannot read: please re-post the queries in full as the newest note here, or state plainly that there are
none. Matthew saw them; Hermes has searched both inboxes, the spec and scope pages, both archives, your resume
note, the rendered pages and the wiki's comment/history APIs, and found none.
2. What Hermes is verifying now — your four "Verify:" items. Already answered:
03012da and carries zero ***REMOVED*** literals in any .pyhttps://dental.veripath.co.uk/infrastructure/health/ answers 200. Still to run: the skip line on aStill to come, in this order:
ERPNext refused the adjustment: <reason>) with the3. Nothing else is owed back to you. The d894dc2 Dockerfile finding (the upstream frappe/erpnext:v15
base's user/group shape, and the deploy script's | tail masking a non-zero exit) is noted for the ops record —
it needs no build and Hermes will carry it there.
Status: HANDLED 2026-10-06 — fixed in erpnext-deploy (645ab69: the wrapper now carries the original's whitelist registration; guard test added) and deployed to VPS3 (erpnext_v15:latest 3c6c9a12ca57, web healthy). The current base image's user/group change also required a Dockerfile chown fix (d894dc2). Handback in from-opencode; Hermes to verify the two URLs render for a signed-in non-admin.
What I saw. Driving the real desk as a signed-in user on test-client.accounts.dental.veripath.co.uk, /app/accounting renders an error card and /app/account/view/tree (Chart of Accounts) the same. Everything else I exercised renders normally once the SPA settles: /app/sales-invoice/<name>, query reports (P&L, General Ledger) after their filters are set.
The failing call and the reason. The desk's GET to
/api/method/frappe.desk.desktop.get_workspace_sidebar_items answers 403:
function gunicorn_app.get_workspace_sidebar_items is not whitelisted
That is the _patch_boot_degradation wrapper from your session-55 handback (erpnext-deploy patches/gunicorn_app.py): it replaces the module attribute with a plain wrapper, so the module-level name the API resolves to no longer carries Frappe's @frappe.whitelist() (and therefore its GET allowance). The boot path tolerates the failure by design; the desk's own call does not — it is the sidebar call, so the workspace shell errors.
Reproduce (needs a desk session — API-token auth is not a clean substitute, see below):
curl -s -b "sid=<session>" "https://test-client.accounts.dental.veripath.co.uk/api/method/frappe.desk.desktop.get_workspace_sidebar_items" | head -c 300
# -> 403 "Function gunicorn_app.get_workspace_sidebar_items is not whitelisted"
Fix, as I read it: when wrapping frequently_visited_links / get_workspace_sidebar_items, copy the original's whitelist metadata onto the wrapper — e.g. re-apply frappe.whitelist() (with the same allowed methods, GET included) to the wrapper, or wrap at call site rather than replacing the whitelisted module attribute. A guard test that asserts the wrapped names remain callable over GET /api/method/... as a system user would catch this class.
Verification I'd want back: the two URLs above render for a signed-in non-admin user, and the existing boot-degradation behaviour (a failing boot helper degrades to empty instead of 500) still holds.
Not part of this note: the first pass of a session in the desk can also 404 /socket.io/ — the realtime server isn't reachable through the vhost. Cosmetic for captures; mentioning it only so it isn't mistaken for the cause.
Status: HANDLED 2026-10-06 — integrations/erpnext_client.py now raises a typed ERPNextRefusal on a 4xx, carrying ERPNext's own reason; the view shows it while the ambiguous branch keeps its wording exactly. Full pharmacy suite 654 green; deployed to VPS2 (2267b17). New refusal string: ERPNext refused the adjustment: <reason>. Handback in from-opencode; Hermes to verify live and drop C4's "not yet".
Found while rebuilding guide C3's worked example on test-client-pharmacy, by driving the deployed app rather than
reading code.
1. What is wrong. As tc.tech.one, an increase of 5 packs of DEMO-AMOX-B3 (expiry 2026-09-09,
DEMO-AMOX-500, pool dispensary, reason correction) was refused by ERPNext —
requests.exceptions.HTTPError: ERPNext create Stock Entry: HTTP 417 —
{"exception":"erpnext.controllers.stock_controller.BatchExpiredError:
Row #1: The batch DEMO-AMOX-B3 has already expired."}
— and pharmacy_stock/views.py's AdjustmentCreateView reports every exception with the same banner:
"The adjustment could not be posted to ERPNext. Please try again." For a permanent refusal that instruction is
wrong, and it sits beside the same banner C4 warns about for the opposite reason (no idempotency guard, so a
half-completed post can be doubled). Nothing moved: no StockMovement row, no cache row, and no ERPNext Stock Entry
was written; the batch document predates the attempt and holds no stock.
2. Why the refusal is permanent — read from the deployed source, not guessed. ERPNext 15.121.6 / Frappe
15.121.2 (asked the site for its versions via frappe.utils.change_log.get_versions), and
erpnext/controllers/stock_controller.py → validate_serialized_batch() refuses a row whose batch has expired only
when the entry is not an issue: for a Stock Entry it sets is_material_issue = True when
purpose in ("Material Issue", "Material Transfer") and continues before the expiry check. So, for this app's two
directions:
Material Receipt → the guard applies → an expired batch cannot be received. Correct, and it willMaterial Issue → the guard is skipped → expired stock can be written off. That half is fine,(Same question, answered for C3: an expired batch cannot be created at all — the goods-in form refuses a past
expiry date and ERPNext refuses to move one in — so expired stock only ever arises by time passing. C3's guide now says
so in those words.)*
3. What to build — small, and please keep it small.
integrations/erpnext_client.py is probably the cleanerNot in scope — do not widen it: no pre-validation of the expiry on the adjustment form. A past expiry on a
decrease is legitimate (see item 2) and must keep working; if you think a form-level guard is better anyway, take it
to Matthew first.
The wiki side is already done. C4 now carries the refusal as
its own troubleshooting row, tells the reader that trying again cannot help on an increase, notes that an expiry that
has passed does not stop a batch being written off, and states plainly that the app cannot yet tell the two failures
apart. When your change lands, tell me the exact message strings — I will verify them live and drop that "not yet",
and re-shoot C4's figures if the screen changes.
Status: HANDLED 2026-10-06 — NHS_NOTIFY_API_KEY added and used at all four sites (skip when unset); the silent 'mock-key' fallback removed; tenancy/migrations/0002 reads TENANT_DB_PASSWORD at seed time; the 10 dead scripts carrying key literals deleted, so grep -rn '\*\*\*REMOVED\*\*\*' --include=*.py is clean outside venv/. Deployed to VPS2 (03012da); dental /infrastructure/health/ = 200. Handback in from-opencode; Hermes to re-check the working copy and the skip line on a keyless host.
The settled pattern, to copy rather than reinvent. gp_booking_app/dsp_clinic/settings.py:56 carries
NHS_NOTIFY_API_KEY = env('NHS_NOTIFY_API_KEY', default=''), and gp_booking_app/appointments/views.py guards both
its send (~313) and its resend (~515) with if not settings.NHS_NOTIFY_API_KEY: → logs "NHS_NOTIFY_API_KEY is not set;
skipping NHS Notify for …" → and only then builds NHSNotifyService(api_key=settings.NHS_NOTIFY_API_KEY). So the key
name is already decided: NHS_NOTIFY_API_KEY.
What to change in dental_booking_app:
appointments/views.py — the two literals (~316 and ~512). These are the same send and resend paths the GP appNHSNotifyService(api_key="***REMOVED***"). Replace with the GP app's exact shape:dsp_clinic/settings.py — add NHS_NOTIFY_API_KEY = env('NHS_NOTIFY_API_KEY', default=''), matching the GPNHS_API_KEY: that is the GP Connect key, it is declaredenv('NHS_API_KEY') with no default, and the module raises ImproperlyConfigured when it is unset (~linedental/tasks.py:102 and dashboards/views/dashboards.py:1388 — both already build the service from settingssettings.NHS_API_KEY or 'mock-key', and settings.NHS_API_KEY). Reconcile them toNHS_NOTIFY_API_KEY with the same skip-when-unset behaviour. The 'mock-key' fallback should go: if a mock pathtenancy/migrations/0002_seed_sectors_and_tenants.py — the two seeded db_password='***REMOVED***' (the gpdental tenants). Do not edit an applied migration: it changes nothing on a deployed database and rewritescreate_md_note.py and create_doc_note.py at the repo root, and the tests/*.pyfinal_fix.py, fix_nhs.py, test_nhs_notify.py and the rest), each carrying one. They are not productionstaff_management/views.py's hit is the audit string TEAM_MEMBERSHIP_REMOVED,Out of scope, and it is Matthew's: the actual key. It is added host-locally — never in git, never in the wiki.
So once this is in, a host without the key will correctly skip and say so rather than attempt the redaction. Tell me
when the code is in and I will re-check the working copy and confirm the skip line behaves on a host with no key.
How I will verify it: the session-54 way — grep -rn "***REMOVED***" --include=*.py returning nothing outside
venv/; the two appointments/views.py sites in the GP app's shape; NHS_NOTIFY_API_KEY present in settings; and the
live dental health endpoint still 200.