Archived from
agents/handoff/from-opencodeon 2026-09-02 (housekeeping — inbox token-efficiency pass peragents/handoff/index).
Further archive pass 2026-09-09 (housekeeping — same convention).
Further archive pass 2026-09-12 (housekeeping — same convention).
All notes below are HANDLED or superseded — kept as the audit trail.
Further archive pass 2026-09-15 (housekeeping — pharmacy Phase 2–4 reports HANDLED; older dental notes superseded/parked).
Further archive pass 2026-09-18 (housekeeping — 8 HANDLED notes moved from the live inbox).
Further archive pass 2026-09-26 (housekeeping — the last 9 HANDLED notes moved from the live inbox).
Further archive pass 2026-09-27 (housekeeping — the 7 HANDLED reports of 2026-09-26/27 moved from the
live inbox, which now holds no open notes). The inventory at the pass: 7 notes held, all HANDLED, all moved.
NOTE: this page is now ~260K characters — read it in slices.
A few notes archived from earlier passes still readStatus: OPENorPARTIAL— those are superseded by the later notes in this page and are not live. The archive is a closed record; the live inbox is the only place an open item can be.
Further archive pass 2026-09-21 (housekeeping — the 2026-09-19 pharmacy build report moved from the live
inbox; that inbox now holds no open notes).
Further archive pass 2026-09-21 (housekeeping — the two HANDLED pharmacy reports of that day, including the supply-ordering build; that inbox now holds no open notes).
Newest on top (original order preserved).
Status: HANDLED 2026-09-27 — DEPLOYED AND VERIFIED LIVE by Hermes (the deploy you left to me is done, plus the
verifier extension).
Deployed on the app host at 1d36ddf (migration 0013 applied; the held VMP file copied over from VPS1 — the app
host has no dm+d release), then reference_data_control_drugs, reference_data_part_two_groups,
reference_data_derive_prices, containers restarted. reference_data_check: all checks passed.
Your correction to my checksum is right and I confirmed it independently: the file has 24,662 entries in two
shapes, and the 179 with <CATDT>/<CAT_PREVCD> include 139 in Schedules 1–3 — my one-shape parse would have left
139 controlled drugs on a category deduction. Good catch; the record says so (spec §9.3).
Verified, not taken on report: reference_data_control_drugs loaded 24,662 entries / 964 in Schedules 1–3 —
my own reading of the file; all 4,439 figures recomputed independently including the new group path — 0 wrong on
basis, rule, rate and net; deduction kinds category 3,248 · none_concession 216 · none_group 377 ·
none_part_two 598 with the 598 item-level rows unchanged; 263 derived packs are Schedule 1–3 VMPs and 0 are left
with a category deduction (250 none_group, 13 none_concession); a second control-drugs run reports 0 rows
changed and a second rebuild is identical; suites reference_data 220 OK (2 skipped), pharmacy_medicines 35
OK, combined 178 OK; verify-derived-pricing.py 26/26; client surfaces
verify-derived-pricing-live.py 27/27 — a controlled-drug pack names the group with no rate applied — and the
pack-link verifier is 32/32 (no regression). Both admin views 403 anonymous; the queue view's "Group rules —
applied and not applied" table carries status, member count, derivation and date.
Answering the thing you did not verify: no Schedule 1–3 VMP sits behind a figure without a basis — the 263 affected
packs all had one, and the 13 that are on concession take the concession path first.
Two small notes, neither a defect: the bundle was not beside the deployment (/root/work/bundles/ on the app
host had no part-two-group-rules.bundle — I copied it across), and the member rows carry the dm+d release while
the group rows carry the tariff release, so filtering members by the group's release returns nothing (it cost me a
cycle; a comment on the model would save the next reader). Nothing outstanding on this build. Platform branch
gp_booking_app: feature/part-two-group-rules @ 1d36ddf, off 1c6f9a3
(the deployed phase-7 revision) — bundle
/root/work/bundles/part-two-group-rules.bundle (delta off 1c6f9a3, ~14 KB),
also /root/work/bundles/part-two-group-rules.bundle beside the deployment.
1 migration, tracked: reference_data/0013_dmdvmp_control_drug_category_code_and_more.
No pharmacy code change was needed (see item 4). No image build (stdlib only).
Headline: the brief's checksum drops 179 VMPs — I read the file and corrected it. The
CONTROL_DRUG_INFO block in f_vmp2_3170926.xml has 24,662 entries, not 24,483. The brief's
figure came from a parse that matched only the <VPID><CATCD> shape and silently dropped the 179
entries that also carry <CATDT>/<CAT_PREVCD> (the schedule changed). Those 179 are real
scheduled drugs: the corrected figures are 0000 23,184 · 0002 597 · 0003 3 · 0004 55 · 0005 165 ·
0006 144 · 0008 100 · 0009 271 · 0010 143, and 964 VMPs in Schedules 1–3 (0001–0007), not 825.
Left uncorrected, 139 controlled drugs would have taken a category deduction. The loader reads
both shapes; the test asserts the corrected numbers against the held file (skips if absent), plus a
synthetic file with one of each shape.
What was built
DmdVmp.control_drug_category_code (migration 0013); the loader readsCONTROL_DRUG_INFO in the VMP file (both shapes) and maps CATCD to the existingCONTROL_DRUG_CATEGORY lookup.manage.py reference_data_control_drugs --file …/f_vmp2_3170926.xml sets the column by category (≤11 UPDATEs, not per-VMP), records aReferencePartTwoGroupMember (release · group · vmp_id ·manage.py reference_data_part_two_groups from the store:curated). The two rules with no signal — cold chain storage and unlicensedapplied=False with a dated derivation. Idempotent.derive.py now yields none_group (the enum value phase 7 alreadyrule_text and in sources.part_two_group; precedence isrebuild_derived_prices task refreshesrule_text, whichpharmacy_medicines mirror unchanged; none_group fitsmax_length=20). The platform Part II queue gains a generated, dated "Group rules — appliedreference_data_part_two.py is unchanged; the group rules it ingests are the rows this buildTests: reference_data 220 OK (1 skipped) on the host suite (was 216 before this build; the
new module adds 7 tests + 1 view test, plus 2 in the loader suite). New module
reference_data/tests/test_part_two_groups.py.
Deploy steps (app host, gp_booking_app)
git fetch /root/work/bundles/part-two-group-rules.bundle feature/part-two-group-rules
git checkout -b feature/part-two-group-rules FETCH_HEAD # or reset an existing branch
python manage.py migrate reference_data # 0013
# copy the held vmp file beside the deployment (it is on VPS1, not the app host):
python manage.py reference_data_control_drugs --file /path/f_vmp2_3170926.xml
python manage.py reference_data_part_two_groups
python manage.py reference_data_derive_prices
Then restart gp_booking_app + worker + beat. reference_data_check should still pass.
Acceptance I would check (spec §9.1): the CD/insulin/vaccine/immunoglobulin packs derive no
deduction and name the group; the 598 item-level rows are unchanged (this build only adds the
group path); the group sets are readable as data with their derivation; the two unapplied groups are
named; a second run is idempotent; suites green; and no scored match decides anything.
Not done by me, for you to run or tell me to: the deploy itself (I built and bundled on VPS1; I
did not touch the app host or the live store), and the verify-derived-pricing*.py extension for the
new position. One thing I did not verify: whether any of the 964 Schedule 1–3 VMPs sit behind the
4,439 derived figures without a dm+d tariff price — the group path only changes rows that already
have a basis.
============================================================
No open notes — phase 7 (the derived pricing) and the pack-panel state split are HANDLED and verified live
(2026-09-27). Everything below it is HANDLED; handled notes move to
Status: HANDLED 2026-09-27 — verified live by Hermes on the app host and on the client surfaces.
Everything reproduced, and the figures were verified against their inputs rather than their own columns.
verify-derived-pricing.py 26 PASS / 0 FAIL; suites reference_data 209 OK (1 skipped),
pharmacy_medicines 35 OK, combined 178 OK. All 4,439 stored figures recomputed independently by me — basis
(concession price via the link, else the dm+d tariff price), the rule selected, the rate, the net, and that every
none_part_two row resolves to a matched Part II item: 0 wrong on each, and no row falls into the brands default
without category words. The Part II list is the document one for one: independently extracted from the September
2026 PDF, 1,462 stored / 1,462 in the document / 151 (ALL) on both sides / no line on one side only — nothing
dropped, nothing invented; 1,433 matched (98.0%), 29 queued, every one decided by rule.
The rate mapping is checked against the document, not taken on trust: Part V Table 1 (September 2026, printed p.54)
gives Appliances = Part IX = 9.85%, Generics = Category A or M of Part VIIIA = 20%, Brands = "products not classified as
an appliance or not listed in Categories A or M" = 5% — your three-way mapping and its default-to-brands branch are
literally the published inclusion criteria. And the sentence that makes phase 7 possible — "The percentages in the table
above will not apply to any product in 'Part II Drugs for which discount is not deducted'" — is the rule you implemented.
Client surfaces over HTTPS through the real OIDC login (tc.tech.one,
test-client-pharmacy.pharmacy.veripath.co.uk), new veripath-ops/scripts/verify-derived-pricing-live.py, 22 PASS /
0 FAIL: the concession pack shows £3.61, the basis "this month's concession price", the rule "On concession this
month, so no discount is deducted." and all three releases; a Part II pack (Ambrisentan 10mg tablets 30) shows the
un-deducted price with "a drug for which discount is not deducted" and no rate; a pack the month omits shows the new
no_line state with no figure; a pack with no price at all names the missing input and never £0.00; W3 unchanged
(216 lines). The extended pack-link verifier is 32/32, and pharmacy-price-counts.py is identical either side
(audit 235 / files 11 / rows 17 / cache 5). The six admin surfaces are 403 anonymous, beat rebuilds at 04:45.
Two things recorded, neither a defect in what you built (full text: the spec's §9.1 and §9.2):
(ALL) markers. §9.2 carries theRead the specs: The Derived Pricing — Phase 7
and §9.1 of the pack-link spec. Built
both in one pass as briefed.
Branches / commits (bundles beside the deployment at /root/work/bundles/):
gp_booking_app: feature/derived-pricing @ 1c6f9a3, off 0907677 — derived-pricing-platform.bundle;pharmacy_booking_app: feature/derived-pricing @ 78c9b2c, off e257181 — derived-pricing-pharmacy.bundle;veripath-ops @ a937b71 — scripts/verify-derived-pricing.py.Deployed by revision on the app host. Migrations: platform reference_data/0012_part_two_and_derived_pricing;
pharmacy pharmacy_medicines/0004 (state-only managed=False mirror). No image build (stdlib — pypdf was already in
the image). Containers restarted: gp_booking_app + worker + beat, pharmacy_app + worker + beat.
Live state:
2026-09 tariff release (manage.py reference_data_part_two): 8 group rules,manage.py reference_data_derive_prices): 4,439 rows, matching the 4,439 pricedlinked, £3.61, basis=concession, deduction=none_concession;no_line with no figure.dsp_clinic.test_settings): reference_data 209 OK (1 skipped); pharmacy_medicinestenancy pharmacy pharmacy_dispensing pharmacy_medicines 178 OK.What was built (spec §3–§6):
reference_data/part_two.py — discover the Part II document on the hub page (never a constructed URL), strip the(ALL) case). No scoreReferencePartTwoGroup, ReferencePartTwoItem, ReferencePartTwoOverride.reference_data/derive.py + DerivedPackPrice — one figure per pack: the month's concession price where the link/infrastructure/reference-data/packs/ — search a pack, see the figure,linked, not_linked (queue), no_line ("Nono_source; W3 is untouched.One number to note: the September document parses to 1,462 items with exactly 151 (ALL) markers (the spec
says 1,463 — a one-line difference in the published list; it changes no figure). The two rules resolve 98.0% (spec
≥98%).
Not built (as scoped): Part IX / Part IIIB / Part IV prices; the endorsement workflow and the unlicensed-specials
route; the supplier-price advisory beside the figure (deferred to phase 8 — there is no dm+d↔StockItem join yet);
pharmacy_accounting is untouched; the month-boundary "previously £X"; Welsh/Scottish fee variants.
Please verify live: re-run scripts/verify-derived-pricing.py in the platform container, the suites, and the pack
panel + W3 on the test client (the pack-link live verifier extended for the new state).
Everything below is HANDLED; handled notes move to
From-OpenCode — Archived Notes (2026-09).
Status: HANDLED 2026-09-27 — verified live by Hermes on the app host, independently of this report.
Everything in the report reproduced, and the client surfaces were verified separately (they were not covered by your
verifier). verify-pack-link.py 23 PASS / 0 FAIL on the app host (veripath-ops @ fea4275); suites
reference_data 182 OK (1 skipped), pharmacy_medicines 33 OK, combined 176 OK. Read directly, not from
the verifier's summary: 216 links, all matched, all decided_by='rule', 0 empty vmpp_id, 0 active overrides;
the CPE release 2026-09 imported and not live; the register consent_required=True / consent_granted=False / may_publish=False; the admin pack-link surfaces 403 anonymous; beat rebuild-pack-links at 04:30 and the
fold-in in tariff_news_pull guarded so a linking failure never fails the fetch. Read-only proved on the pharmacy
side (pharmacy-price-counts.py identical either side: audit 235 / files 11 / rows 17 / cache 5).
New, and it is criterion 7 — the client surfaces over HTTPS through the real OIDC login (tc.tech.one,
test-client-pharmacy.pharmacy.veripath.co.uk): veripath-ops/scripts/verify-pack-link-live.py (committed e49051b)
31 PASS / 0 FAIL — W3 216 lines across 7 notices, each naming its linked pack, none "not yet linked", none
blank, none £0.00; the Aciclovir 400mg tablets 56 panel shows the pack's own line at £3.61 with its notice
(issued 16/09/2026); a pack the month does not list (Anadin Paracetamol 500mg tablets) shows the honest unresolved
state with no figure invented; no CPE attribution anywhere client-facing; anonymous W3 302. Full record:
spec §9. Nothing is handed back to you from this build.
One item for you to look at, not this build's code: the pharmacy worker logs
DefectDojo finding creation failed: 404 ... https://security.veripath.co.uk/dojo/api/v2/findings/ on each Lynis
scheduled scan (twice in the last 24 h) — that path has been failing since the DefectDojo move to VPS1 (2026-09-13).
Worth a look at whether the API base path moved; it is otherwise silent. No ruling needed from Matthew.
Read the spec: Data Feeds — the Pack Link
(the brief is in from-hermes). Built v1 exactly as scoped: no fuzzy matching, and none
of the phase 7/8 items.
Branches / commits (handed over as git bundles beside the deployment at /root/work/bundles/):
gp_booking_app: feature/pack-link @ 0907677, off e0d4f9a — pack-link-platform.bundle;pharmacy_booking_app: feature/pack-link @ e257181, off 3f45af7 — pack-link-pharmacy.bundle;veripath-ops @ 69fb1c8 — scripts/verify-pack-link.py.Deployed by revision on the app host: both working trees read as feature/pack-link (code is bind-mounted, no image
build). Migrations applied: platform reference_data/0011_pack_link; pharmacy pharmacy_medicines/0003 (state-only
managed=False mirror). Containers restarted: gp_booking_app + worker + beat, pharmacy_app + worker + beat.
Live state — the month was linked on the app host: 2026-09 vs dm+d 3170926: matched=216 needs_decision=0 unmatched=0. The verifier re-ran there 23 PASS / 0 FAIL:
18239411000001109), not the 50 gram one;needs_decision and settles again on the re-link; an override is held as avmpp_id resolves against the live release.Pharmacy smoke (live): the pack panel reads state=linked, price=£3.61, notice=2026-09-16 for the Aciclovir pack;
W3 lists 216 lines, 216 linked, 0 not linked, each line carrying its own pack.
Suites on the app host (dsp_clinic.test_settings): reference_data 182 OK (1 skipped); pharmacy_medicines
33 OK; combined tenancy pharmacy pharmacy_dispensing pharmacy_medicines 176 OK.
What the build does (spec §3–§5):
reference_data/pack_link.py — normalise both sides (case, whitespace, number↔unit, /, a small synonym table);ConcessionPackLink (one row per line + dm+d release: outcome, how decided, who/when, candidates) andConcessionPackOverride (a human decision keyed on the normalised (drug, pack size), held for every future month,tariff_news_pull) and a daily taskreference_data.tasks.rebuild_pack_links, 04:30); reference_data_pack_links rebuilds it by hand./infrastructure/reference-data/pack-links/ (one action to link; override list withmanaged=False mirror.Read-only on the pharmacy side (mirror models, no write path; ReadOnlyTests still green). The CPE gate stays
shut — no CPE row can render.
Not built (as scoped): the StockItem → pack bridge (phase 8), the derived pricing arithmetic (phase 7), the
Part II discount-not-deducted list (phase 7), and the month-boundary "previously £X" (recorded, not built).
Please verify live: re-run scripts/verify-pack-link.py in the platform container, the platform/pharmacy suites,
and read the panel + W3 on the test client.
Status: HANDLED 2026-09-27 — all four items confirmed by Hermes on the app host. Everything below was reproduced
here, not accepted on report: reference_data 164 OK (1 skipped), pharmacy_medicines 31 OK, the combined
tenancy pharmacy pharmacy_dispensing pharmacy_medicines suite 174 OK, and the tightened verifier 47 PASS /
0 FAIL (run from veripath-ops @ 9396fc7). Both repos read as named branches with clean trees, the bundles are at
/root/work/bundles/ beside the deployment, the migrations are tracked, and the only live alert is the unrelated dm+d
key notice. The four notes are closed with evidence in every case. Nothing outstanding on this build.
Deploy (defects 1–3)
gp_booking_app onfeature/nhs-pricing-view @ e0d4f9a and pharmacy_booking_app onfeature/pharmacy-price-concessions-view @ 3f45af7 — both working trees clean./root/work/bundles/ (not in /tmp on VPS1).reference_data 164 OK (1 skipped); pharmacy_medicines 31 OK;tenancy pharmacy pharmacy_dispensing pharmacy_medicines 174 OK.git checkout/git stash can no longer silently revert the build, and migrations 0009/0010 (platform) and the0002 mirrors are tracked, not untracked.The four smaller notes
basis + fee (412p) — the net != net + 152 tautology is gone.216 is no longer asserted against the stored rows: the check is effective == 216 and rows >= effective, so ais_signal_only), removing thelagging_signals carries that case (live: 0 lagging, 0 unmatched).catalogue.concessions_release() now consults source.may_publish, so the register stays the single switch; a testRe-verified live: verifier 47/47 (with the new checks); health row current; the only alert is the unrelated
dm+d key notice. Commits: 8d5576d, e0d4f9a (platform); 3f45af7 (pharmacy); 9396fc7 (veripath-ops).
Recorded, not built (phase 7): the month-boundary amendment's "previously £X" — v1 holds only the current month,
so a value that changed in the previous month loses that note.
Status: HANDLED 2026-09-27 — re-run done by Hermes the same day: 47/47 reproduced on the app host, and the
live state read independently (release 2026-09 live, 7 notices, 216 lines, 0/0/0 against the CPE store, cost inside the
ceiling, the CPE gate still shut, health current). The build is good; the deployment is not — three deploy defects
(running tree not a revision; test files undeployed so the host reports 136 not 163; the pharmacy suite red at 18 tests
with 2 errors) and four smaller notes are in from-hermes. Full record: spec §8. Two
platform defects are also fixed.
Read the spec: Data Feeds — NHS Pricing View
(phase 6 v1, W6 included).
What was built
Platform (gp_booking_app, branch feature/nhs-pricing-view, off 8abc4bd) — bundle
/tmp/nhs-pricing-view.bundle (requires 8abc4bd):
921a59c — the IMAP PracticeMailbox import fix (defect item 2), also in the pharmacy repo;fe2dff6 — reference_data_check closure checks + money rule rewritten to correlated Exists() (defect item 3);c636ae3 — the NHSBSA Drug Tariff News source (drug_tariff_news, routine nhsbsa_notices), the notices005e2ce — the fetch attempt records http_status and the page size.739dc04 — the signal matcher uses the email’s own date and retries every unmatched signal on each poll.default: reference_data/0009 (the nhsbsa_notices routine + ReferenceMailSignal) andreference_data/0010 (creates the drug_tariff_news source row — no re-seed needed). No image build (stdlib).reference_data.tasks.fetch_tariff_news (daily 04:00) andreference_data.tasks.signal_concession_mailbox (every 15 min, replacing the scheduledpoll_concession_mailbox, kept for manual use). Manual: reference_data_fetch --source drug_tariff_news.Pharmacy (pharmacy_booking_app, branch feature/pharmacy-price-concessions-view, off 27dc604) — bundle
/tmp/pharmacy-price-concessions-view.bundle (requires 27dc604):
dfee1ed — the IMAP import fix;d212da1 — the mirrors, the pricing panel on the pack detail page and W3 /medicines/price-concessions/;e12258e — the raw pence figures the verifier asserts.pharmacy_medicines/0002 (state-only managed=False mirrors; no table touched).Live state on the app host (deployed in place, bind-mounted)
reference_data migrated; the notices fetch ran: release 2026-09 published live, 7 notices, 216 rows,ReferenceFetchAttempt: 574,887 bytes, 0.25 s, HTTP 200 — inside the ceiling.veripath-ops/scripts/verify-nhs-pricing-view.py — 47/47. Run:docker exec -i pharmacy_app python manage.py shell < /root/work/veripath-ops/scripts/verify-nhs-pricing-view.py.pharmacy-price-counts.py unchanged either side of a verifier run.reference_data 163 OK (1 skipped); pharmacy_medicines 30 OK; tenancy pharmacy pharmacy_dispensing pharmacy_medicines 173 OK.Defects
NameError): fixed in both repos and verified live — poll_imap_mailboxes now succeeds on bothreference_data_check never finishing): rewritten; on the live dm+d release it now completes, all ten0 dangling and the money rule clean.redis alias residue on dental_redis / pharmacy_redis (phase-4 note item 1b).One thing to note: the mailbox is signal-only now — it reads the envelope, records the Message-ID and triggers the
notices fetch; it does not parse a body and does not retain a .eml. The phase-5 parse/gate and the two retained
.eml files are untouched, so verify-concessions-live.py and the phase-5 rows are unaffected. The signal poll was verified live: the 5th and 6th update alerts were recorded with their real
email dates and matched to the 24/25 September notices.
Status: HANDLED 2026-09-27 — verified live by Hermes the same night. Deployed to the app host (8abc4bd,
migration 0008, no image build); the real mailbox was ingested — 5th update 182 = 182, 6th 216 = 216,
release 2026-09 imported and never live (the gate holds); the live verifier is 37/37, the suite is
136 OK (1 skipped), and the same-day cross-check against CPE's own page matched exactly (216 rows, 0
missing, 0 extra, 0 prices differing). Full record: spec §17. One configuration finding is worth reading
there: the app reads config/secrets/env.production, not .env, so the concession group now lives in the former.
Nothing is handed back — the build is closed.
Built the authorised spec
(Price Concessions — Phase 5 Ingestion)
on branch feature/price-concessions-ingestion, one commit off the current
line (5bf0857, the redis fix, already carrying phase 4):
8abc4bd; bundle /tmp/concessions-8abc4bd.bundle (requires5bf0857) — git fetch /tmp/concessions-8abc4bd.bundle feature/price-concessions-ingestion.default: reference_data/0008 (the cpe_email routine,no_message/ignored attempt statuses, the two tables, and a data stepprice_concessions at the new routine — no re-seed needed).imaplib, ssl, email). A restart suffices.reference_data.tasks.poll_concession_mailbox, beat every 15 min.python manage.py reference_data_fetch --source price_concessions.scripts/run_reference_data_tests.sh — 136 pass, 1 skipped.Evidence (local to this host):
.eml files: 5th£92.86 (previously £85.45)); gate clean. Noquery.live_release('price_concessions') is None, so nothing isAcceptance map (§10): 1 routine + missing-mailbox config fault; 2 unattended +
duplicate no-op; 3 update list, retained .eml hash-identical; 4 arithmetic
gate (real 182/216); 5 five doctored inputs held; 6 override + rollover; 7
ordinal-gap alert; 8 consent closed; 10 no dependency/credential/tenant writes;
11 upload path == poll; 12 late earlier update does not overwrite. §10.9 (the
same-day cross-check against CPE's page) is a live verification step for you.
Two notes for the live run: the mailbox env group is read as
REFERENCE_DATA_CONCESSIONS_IMAP_{HOST,PORT,USER,PASSWORD,FOLDER,USE_SSL} plus
REFERENCE_DATA_CONCESSIONS_{SENDER_DOMAIN,SUBJECT_FILTER} — values are already
in the app host's .env, and I have not moved or printed them. The poll is
read-only: no \Seen filter, no flags, scoped server-side to sender + subject.
Also still open from the earlier from-hermes note (not this build): the
lab_documents/tasks.py IMAP NameError and the reference_data_check closure
cost — out of scope this session by Matthew's steer.
Status: HANDLED 2026-09-26 — verified live by Hermes the same day. Release 2026-09 published
unattended in 27 s (appliances 9.85%, generics 20%, brands 5%, SAF 152p); the two verifiers pass
49/49 and 16/16; the suite is 108 OK (1 skipped); the gate refuses five doctored documents and a
doctored month holds without publishing; dm+d untouched. Full record: spec §12. Nothing is handed back.
Two platform defects surfaced by the verification — the redis alias collision on the shared network, and a
NameError in the 5-minute IMAP task — are written up in
from-hermes and await Matthew's decision; neither is this build's code.
Built the approved D11 spec
(The Drug Tariff — Phase 4 Ingestion)
on branch feature/drug-tariff-ingestion, two commits off 45b8dd8 (the
current line):
5009f90 the build; c312557 the deploy data migration./tmp/drug-tariff-c312557.bundle (requires base 45b8dd8) —git fetch /tmp/drug-tariff-c312557.bundle feature/drug-tariff-ingestion.default): reference_data/0006 (the tariff_fetchReferenceTariffParameter table); 0007 (setsdrug_tariff.routine='tariff_fetch', so deploy does not depend on re-seeding).reference_data.tasks.fetch_drug_tariff, beat 03:45 daily, apython manage.py reference_data_fetch --source drug_tariff.pypdf==5.9.0 added to requirements —docker compose build app celery_worker celery_beat then up -d.scripts/run_reference_data_tests.sh — 108 pass, 1 skipped.Evidence (local to this host):
/2026-08/), the tariff2026-09.Acceptance map (§8): 1 health shows the real routine/cadence (0007); 2 unattended
publish and a second-run no-op; 3 the four parameters on the release detail;
4 the gate holds and the live release is untouched; 5 overdue from the 6th with
the month named; 6 the Table-3 trap refused; 7 attribution in the licence
snapshot; 8 no credentials, no dm+d writes, dm+d tests pass.
Please verify live on the app host and record it on the spec page; Matthew then
checks /infrastructure/reference-data/health/ and the month's release detail.
============================================================
Status: HANDLED 2026-09-21 — verified on the app engine; over to you for the live check and the C6 guide.
Repo: dev = 38e2271 (branch feature/pharmacy-supply-ordering). Migrations pharmacy_stock.0003 and
pharmacy.0017 applied to the apex (sector_pharmacy) and test-client-pharmacy; pharmacy_app + celery
restarted and healthy; the demo tenant re-seeded (below).
What was built (W1–W9).
StockItem + pip_code (leading zeros preserved), preferred_supplier (FK PROTECT), order_up_to_level.Supplier + supplier_code (AAH/ALLIANCE/PHOENIX/OTHER), account_reference, export_profileGENERIC), integration_profile (CSV_EXPORT default; DIRECT_API a stored label, marked notminimum_order_value (display only). Existing supplier screens extended.pharmacy_stock/order_profiles.py — one GENERIC entry (ProductCode,Quantity, code = pip_code elsebarcode); the only place a column name or filename appears.Order/OrderLine in pharmacy_stock (DRAFT/EXPORTED/RECEIVED/CANCELLED, no delete, cancel-with-reason)./stock/orders/new/ builds from the cached positions, grouped by preferred supplier, with Last/stock/orders/<pk>/export/ (POST-only) streams the profile CSV (UTF-8 no BOM, LF, comma), marks theORDER_EXPORTED. Moves no stock, raises no ERPNext entry, writes no cache./stock/orders/ and /stock/orders/<pk>/; GoodsInReceipt.order (SET_NULL) + an optional AgainstPharmacyStockManageRequiredMixin on every order surface; no Counter Assistant surface. New auditORDER_CREATED/UPDATED/EXPORTED/CANCELLED/RECEIVED./stock/orders/<pk>/edit/), refused once exported.W9 evidence.
Ran 369 tests ... OK (was 352).GENERIC registry produced '0123456' for DEMO-AMOX-500; the file's first two lines areProductCode,Quantity then 0123456,<qty>. DEMO-SALB-100 (no PIP code) falls back to DEMO-BARCODE-0003.test_export_makes_no_erpnext_call_and_no_cache_write patchesERPNextClient to raise and asserts the export still succeeds with the position cache byte-identical.test_counter_assistant_has_no_order_surface — list, build, detail, edit, cancel and export all 403.test_order_tables_route_to_the_tenant_database (Order writes route to sector_pharmacy;allow_migrate('default','pharmacy_stock') is False). The live central dsp_clinic database holds nopharmacy_stock_* tables (the only match was the unrelated lab_documents_laborder).Demo tenant ready for your live check: test-client-pharmacy re-seeded — DEMO-AMOX-500 (pip_code
0123456, preferred supplier, order-up-to 60), DEMO-IBU-400 (low: on hand 8, suggested 32), DEMO-SALB-100
(barcode fallback); supplier "Synthetic Wholesaler (DEMO)" carries DEMO-ACC-0001, GENERIC, min £150.
Yours: the live byte-compare on test-client-pharmacy, then the C6 guide and C1's "against an order" step.
Nothing else outstanding.
Status: HANDLED 2026-09-21 — verified on the app engine; guides to follow.
A — expiry-date box (Findings 9–10). Built (ad83f9b). Both GoodsInReceiptLineForm.expiry_date and
AdjustmentForm.expiry_date now accept UK %d/%m/%Y and ISO, with a native date picker; the adjustment form
renders every field's error (it previously showed only stock_item/quantity/reason, so a refused date was silent).
The goods-in template already rendered its line errors — no change there. Guides C1/C4: drop the YYYY-MM-DD
workaround.
B — supplier screen (Finding 7). Built (9bfb19c). /stock/suppliers/ (list, all four roles), new/,
<pk>/edit/, <pk>/toggle/ (RP, Technician, Business Manager). No delete — deactivate only, so old receipts keep
the name; duplicate names refused case-insensitively; a supplier with receipts cannot be renamed. Audit
SUPPLIER_CREATED/UPDATED/DEACTIVATED/REACTIVATED (migration pharmacy.0016, applied to apex and
test-client-pharmacy). Supplier also registered in admin. Coverage Plan: add the supplier-management
worklist item; C1's supplier line is now a real first step.
C — position cache. Built (508aea3). sync_position_cache now deletes cache rows in the synced
(item, warehouse) scope that ERPNext did not report, only after the positions were read without error — a failed
refresh leaves the cache intact. Guides C3/C5 can drop the troubleshooting row.
D — supply ordering. No code, as instructed. Draft build spec written to
supply-orders-spec: the safe constraints, the
catalogue gaps, a proposed shape, and Matthew's three questions. Awaiting his ruling before any build.
Deploy: dev 508aea3 pushed; VPS2 fast-forwarded; pharmacy.0016 applied to the apex (sector_pharmacy)
and the test-client-pharmacy tenant; pharmacy_app + celery restarted; routes resolve and the app is healthy.
Still yours: the guide edits (C1, C3, C4, C5) and the Coverage Plan/index updates. Nothing else outstanding
from this pass.
Status: HANDLED 2026-09-19 — verified on the app engine and the guides updated.
Tickets A–C are live on the app engine (commit 5b4e135 on dev, pushed; VPS2 fast-forwarded and
pharmacy_app + celery restarted). Migrations pharmacy_dispensing.0004/0005 applied to the apex
(sector_pharmacy) and the test-client-pharmacy tenant. Full suite: 327 tests OK; the new routes were
verified by resolve() on the running container.
receive() now adopts an entry ERPNextcharge_due derived; unambiguous legacyAnswer you asked for — adjustment retry is NOT safe: services.adjust() has no idempotency guard, so a submit
that succeeds followed by a record_movement failure makes "try again" create a second Stock Entry. Please treat
C4 accordingly until it is fixed.
Credential note (2026-09-16): hardened as far as is safe — details in the HANDLED entry in from-hermes.
Rotation of the master-admin credential and the git-history purge are deferred to a coordinated maintenance step
(Matthew, 2026-09-19): the password is consumed by the VPS2 partner apps and sits in four repos' history.
Correction: the "third hardcoded realm" was an over-count — only the one you fixed was a bug.
Hermes: verified against the running app — 5b4e135 on the app engine, the pharmacy containers
healthy, /stock/receipts/<pk>/ and /stock/receipts/<pk>/retry/ resolving, the exemption field
offering the 16 valid NHSBSA codes, and the endorsement form reduced to the button. Done: B4 and
B5 corrected with fresh figures, and guide C1 published. Carried forward: the adjustment idempotency fix is needed before C4 can be documented, and the Dispensed column on the dispensary
queue (my F5 proposal) is still awaiting Matthew's ruling.
Status: HANDLED 2026-09-15 — deployed and verified live by Hermes. Nothing outstanding from your
side; the one live check you flagged PASSES. origin/dev = 4426e20, two commits (78c1d7c
partner service account, 4426e20 the screen). Runner 309 tests OK (was 291) — confirmed on my
own independent run; makemigrations --check clean; manage.py check clean.
Built to the staff-user-management spec.
The screen. /pharmacy/staff/ (list + inline add-user with a role selector), /pharmacy/staff/<id>/
(detail), toggle/, reset-password/ — in the pharmacy app. Gated by one new mixin,
PharmacyStaffUserManageRequiredMixin (core/permissions.py), Business Manager only. No MFA
control — absent, not disabled, and no route (reverse('pharmacy:staff_user_toggle_mfa') raises).
Templates pharmacy/staff_user_list.html / _detail.html; nav link added for the BM.
Realm resolution is request.partner_org.keycloak_realm else PHARMACY_REALM
(pharmacy/views.py::_staff_realm) — the rule the OIDC backend already uses, so one screen serves apex
and clients.
One action, both sides done right: create resolves the realm, creates the Keycloak user with the role
attribute, joins the matching role group, adds org_id only on a client host, and sets a temporary
password. No app-side CustomUser — OIDC_CREATE_USER builds it from the claims on first login. Guard
rails: no self-disable, no disabling the last enabled Business Manager. Every
create/enable/disable/password-reset writes a PharmacyAuditEvent (STAFF_USER_*) carrying the username +
role + realm in detail (the user row is central, the audit table is client-tier — no FK to depend on).
Security — the design point you asked me to honour. Runtime never uses the master credential. New
RealmServiceAccountClient (partner/utils.py) authenticates with the client-credentials grant against
the realm (/realms/{realm}/protocol/openid-connect/token) using a dedicated client holding only
realm-management: manage-users + view-users. New central model partner.KeycloakRealmServiceAccount
(migration partner.0007) stores the per-realm client id + secret; new command
provision_pharmacy_staff_client --realm <realm> creates the client (service accounts enabled, standard
flow off, confidential), grants the two roles to its service-account user, and records the secret (never
printed). The master credential is used only at provisioning time, never in a request path.
Deploy: migrations partner.0007 (central) and pharmacy.0015 (audit choices — apex + client tier +
migrate_client --slug test-client-pharmacy), then run provision_pharmacy_staff_client --realm pharmacy
and --realm test-client-pharmacy. The screen reports cleanly if a realm is not provisioned.
One thing to verify live that I could only pin in tests: the last-Business-Manager guard and the list's
role column read attributes from GET /users?briefRepresentation=false. Keycloak v15 returns attributes
with that flag; if yours does not, both would read empty — worth one live look. Everything else (gating,
create payload, group join, org_id, temporary password, guard rails, no CustomUser, audit) is covered by
pharmacy/tests_staff_users.py (18 tests).
Files: partner/models.py (+partner/0007), partner/utils.py, core/permissions.py,
pharmacy/views.py, pharmacy/urls.py, pharmacy/models.py (+pharmacy/0015),
pharmacy/management/commands/provision_pharmacy_staff_client.py,
pharmacy/templates/pharmacy/staff_user_*.html, pharmacy/templates/pharmacy/base.html,
pharmacy/tests_staff_users.py.
Deployed and verified against the live artifacts. All 10 acceptance criteria on the spec hold; the check
you flagged is proven, not assumed. Nothing outstanding for you.
Suite, my own run: Ran 309 tests in 142.971s — OK. Matches your count exactly.
Repo: 4426e20 fast-forwarded onto the app engine (13 files, 1028 insertions). Migrations applied —
partner.0007 central, pharmacy.0015 on the apex (sector_pharmacy) and on the client tenant
(migrate_client --slug test-client-pharmacy --app-label pharmacy, Applying pharmacy.0015 ... OK). App
restarted, container healthy.
Service accounts provisioned, both realms idempotently: provision_pharmacy_staff_client --realm pharmacy and --realm test-client-pharmacy — each created client pharmacy-staff-admin, whose service
account held exactly manage-users, view-users, and stored a KeycloakRealmServiceAccount row (secret
present, 32 chars, never printed). The request path never touches the master credential — confirmed by
reading get_realm_service_account_client, which raises rather than falling back.
Your one live check — PASSES. The runtime client, authenticating purely by client-credentials against
the realm, returned GET /users?briefRepresentation=false with the attributes key present on every
user and the role attribute populated. So the list's role column and _enabled_business_managers() both
read correctly on our Keycloak version.
Live by real login, both hosts (OIDC through Keycloak, then direct URL):
| who | host | /pharmacy/staff/ |
|---|---|---|
demo.bm.one (BM) |
apex | 200 — list rendered, add-user form present, role column filled |
demo.rp.one (RP) |
apex | 403 (authenticated, wrong role — not a redirect) |
tc.bm.one (BM) |
client | 200 — and the body lists tc. usernames only, no demo. |
tc.ca.one (CA) |
client | 403 |
Detail pages 200 with both controls present. Realm scoping is proven live: one screen, apex roster and
client roster, each from its own realm.
Apex Business Manager created so the screen is reachable there: demo.bm.one
(Bartholomew Tillworth — the name your SYNTHETIC_STAFF seed uses for bm), role attribute
PHARMACY_BUSINESS_MANAGER + BUSINESS_MANAGER group, enabled.
MFA absence confirmed by grep, not by trust: zero occurrences in pharmacy/urls.py, pharmacy/views.py,
both staff templates and base.html.
Not yet exercised live (test-covered only, deliberately — a create is a real mutation): the create path
itself, toggle/, reset-password/, the two guard rails and the audit rows. I am asking the user whether the
create-path proof should be the missing client-tenant technician, which would make the test do real work.
Status: HANDLED 2026-09-15 — verified and deployed by Hermes. Nothing outstanding from your side.
291 tests OK on my own independent run (matches your claim); makemigrations --check clean confirmed.
Deployed ce072f0 to the app engine by fast-forward (12 files); pharmacy.0014 applied on the apex
(sector_pharmacy) and the client tenant (migrate_client --slug test-client-pharmacy --app-label pharmacy); app healthy, health endpoint 200. Verified live: client e2e PASS (login through realm
test-client-pharmacy + RP cycle), realm isolation PASS 4/4, and provision_pharmacy_client_org --dry-run clean (resolves slug/realm, finds the client secret, writes nothing) — so client onboarding
is now the three documented steps. Your org_id change is proven end to end: tc.rp.one and
tc.rp.two read org_id='test-client-pharmacy' app-side after logging in (both were None), while
tc.bm.one, which has not logged in since, is still None — exactly the on-login behaviour.
Both your corrections accepted and recorded: the RP log was never unsorted
(Meta.ordering since c77688f) — my note was wrong; and the "no BM identity anywhere" census was
stale, the client realm now holds one. Also confirmed: PHARMACY_ROLE_MAP reads the role attribute,
and the role-mapper / org-id-mapper are present on the app client in every client realm.
Next, and it is yours: the staff user-management view is authorised — the build note is on top
of agents/handoff/from-hermes, with the gating decision settled (Business Manager only) and the spec
at development/projects/pharmacy_app/build-records/staff-user-management. A8 stays mine.
Commits: ec005cd (client-org command), 5d8377f (seed roles + reset fix), 920d8b4 (RP-log order),
c65fe40 (ERPNext keycloak_realm), ce072f0 (org_id on the pharmacy OIDC path). Migration pharmacy.0014
— apply on the apex + client tier + the client tenant (migrate_client --slug test-client-pharmacy).
Build ask — provision_pharmacy_client_org (done). New
pharmacy/management/commands/provision_pharmacy_client_org.py: --slug (required), --realm (defaults to
slug), --name (required), --client-id (default pharmacy-booking-app), --dry-run. It resolves the
realm's app client, fetches its secret and upserts the PartnerOrg row; the secret is stored, never printed.
partner/utils.py gained PartnerKeycloakClient.get_client/get_client_secret; the secret is read with
GET (a bare POST regenerates it and breaks live logins) and only generated when absent. Client onboarding
is now the three documented steps — realm, users, org row — and I updated the client-realm runbook.
Seed roles (done). seed_pharmacy_demo now creates one identity per role: {prefix}.bm.one,
{prefix}.tech.one (name aligned with seed_pharmacy_dispensing's demo.tech.one), {prefix}.ca.one,
plus the two RPs — same PHARMACY_DEMO_PASSWORD, --reset sweeps them. App-side only: the matching
Keycloak identities are still yours, so the BM/CA cannot log in until they exist in the realm.
Latent bug the seed test exposed (fixed). seed_pharmacy_demo --reset deleted central CustomUser rows
with the normal collector, which then queried the related pharmacy tables on the central connection and
raised relation "pharmacy_premisesverification" does not exist (users are central, pharmacy rows are in the
tenant DB). It now clears the client-tier rows that PROTECT the users (audit events, RP log, verifications,
premises) and deletes the users with _raw_delete(using='default'). Worth knowing before the next --reset.
RP log — correction. ResponsiblePharmacistLog has had Meta.ordering = ['-signed_on_at'] since the
Phase 1 commit c77688f, so both surfaces were already newest-first; there was no ordering defect. I added
the explicit order_by on both views plus a regression test, and corrected the status on your note.
Decisions taken (agreed with the user).
org_id is consumed on the pharmacy path — users/auth.py writes it in the pharmacy branch ofcreate_user/update_user; apex identities stay null. Tests in users/tests.py (users added to the runner).keycloak_realm added to PharmacyERPNextConfig (migration pharmacy.0014), mirroring dental;provision_pharmacy_erpnext --realm defaults to the tenant's PartnerOrg realm, else the sector realm.Still not authorised: the user-management view — not started. A8 is yours. Carried: net-vs-VAT
counter pricing (Matthew).
Files: pharmacy/management/commands/provision_pharmacy_client_org.py,
pharmacy/management/commands/seed_pharmacy_demo.py,
pharmacy/management/commands/provision_pharmacy_erpnext.py, pharmacy/models.py (+pharmacy/0014),
pharmacy/views.py, partner/utils.py, users/auth.py, users/tests.py, pharmacy/tests.py,
pharmacy/tests_erpnext_provision.py, scripts/run_pharmacy_tests.sh.
Status: HANDLED 2026-09-15 — re-verified by Hermes (276 tests OK on my own run). Acceptance test 1 now PASSES over
the FULL period (ties=True, mismatches=[], unlinked=[], variance 0.00), basket 2 is gone and the real basket 3
was correctly left alone; the scoped movement filter matches the counter's real sale:<pk>:<line> reference format, so
the latent "deletes a real sale's movement" bug is genuinely closed. That was the last build item: every A1–A11
build item is now delivered, deployed and verified — no coding remains. 7 of Phase 6's 8 acceptance criteria hold;
the 8th is A8 (the handover pack), which is mine. One new actionable finding in from-hermes: seed_pharmacy_demo
creates only Responsible Pharmacists, so there is no Business Manager identity and the BM-only accounting reports
cannot be opened by any user, and no Counter Assistant for the counter or notification surfaces.
origin/dev = e6a5fbb, one commit, no migrations, runner 276 OK.
You were right on both counts: the purge fix works, and my Finding-1 change to the reset's identity key is what
orphaned basket 2. _demo_baskets() now matches counter_customer=DEMO_MARKER OR
erpnext_invoice='DEMO-SINV-0001', and both _reset() and the idempotency check use it. I followed your
caution exactly: the sweep keys on the placeholder invoice, never on counter_customer='Counter Customer'
(basket 3 shares that label with a real ACC-SINV-... invoice and is left alone — there is a test for it).
One related fix while in there, and a latent bug it closes: the reset deleted every sale: stock
movement (source_reference__startswith='sale:'), which would have removed a real sale's movement too. It is
now scoped to the demo baskets' own references (sale:<pk>:). Same "don't delete good data" concern you raised
about basket 3.
Suggested sequence: pull e6a5fbb → seed_pharmacy_counter --tenant test-client-pharmacy --reset (now
sweeps basket 2) → re-run A4 over the full period → expect ties=True, mismatches=[], unlinked=[],
gross_variance=0.00.
A5: understood — settled by Matthew, proven, and left exactly as delivered. I will not touch the valuation
again, and Phase 3's zero-valuation goods-in stays as it is. A8: noted as yours; I will not start it. And I
have not touched counter pricing — the net-vs-VAT-inclusive call is still Matthew's.
Where the spec met reality differently (this round)
That is the last build item I am aware of in Phase 6. Report back when you have re-verified.
Status: HANDLED 2026-09-15 — re-verified by Hermes (275 tests OK on my own run). NEW BUG 2 is FIXED and proven
(value and SQL: _expected_scope(None) compiles to (AND: ), no IS NULL; summary returns 422.10 with and
without premises). NEW BUG 1's fix runs — --reset completes with no ProtectedError and posts a real
ACC-SINV-2026-00018 — but it does NOT unblock acceptance test 1: the stale basket is older than the marker
rename and the reset cannot see it. One new finding in from-hermes. A5 (the demo valuation) is with Matthew —
I have not run your command. Your demo-token-endorsed-1 litter is cleared (the purge path works).
origin/dev = 96d64b3, three commits, no migrations, runner 275 OK. Findings 2–4 stay closed.
NEW BUG 1 — fixed, and it is also the purge path you asked for. A token is now PROTECTed by three later
apps, so a bare delete raises. New pharmacy_dispensing/purge.py::purge_tokens() clears every dependent in
dependency order (receipts → expected → notifications → collections → stock movements → endorsement/audit)
and deletes the token by queryset (so the append-only barrier is not tripped). Both
purge_dispensing_tokens and seed_pharmacy_counter --reset now use it. So:
seed_pharmacy_counter --tenant test-client-pharmacy --reset will now run — it clears the stale basket 2DEMO-SINV-0001, then re-seeds and posts a real invoice, which unblocks acceptance test 1.demo-token-endorsed-1 litter is removable now:purge_dispensing_tokens --tenant test-client-pharmacy --reference demo-token-endorsed-1.NEW BUG 2 — fixed. reimbursement_summary() and awaiting_receipt() treated premises=None as
token__premises IS NULL, which matches nothing, while the actuals path applied no filter — hence expected
0.00 against actual 431.10. Both now use _expected_scope(): None means no filter, matching
_receipt_scope. Tests added for the default (no-argument) call.
Acceptance test 5 — a demo valuation (a proposal for Matthew). You were right that it is a decision, so I
did not touch Phase 3's deliberate zero-valuation goods-in. Instead a new demo-only command,
seed_pharmacy_stock_valuation --tenant test-client-pharmacy, receives the synthetic reimbursement items into
the counter warehouse at their reimbursement price (idempotent; skips an item that already holds stock). A5
then reports a real value that equals ERPNext's Bin sum. It is obviously synthetic and creates no accounting
entries beyond the stock receipt. If Matthew would rather demo-valuations live elsewhere (or not at all), drop
the command — A5's code is unchanged and still faithful.
Suggested sequence on the tenant: pull 96d64b3 → purge_dispensing_tokens --reference demo-token-endorsed-1
(clear your litter) → seed_pharmacy_counter --reset (real invoice; acceptance 1) →
seed_pharmacy_stock_valuation (acceptance 5) → re-run A4/A5.
Where the spec met reality differently (this round)
pharmacy_dispensing, not the later apps — it lazily imports the Phase 5/6 models, sopurge_tokens also removes stock movements and audit rows for the tokens, so a purge leaves nothingCarried: the net-vs-VAT-inclusive counter-pricing decision is still Matthew's.
Status: HANDLED 2026-09-15 — re-verified by Hermes (270 tests OK). Findings 2, 3 and 4 are fixed and proven; acceptance tests 2 and 3 now pass live (the band split read from item_wise_tax_detail; MATCHED and SHORT receipts). Finding 1's fix is real (a real ACC-SINV-2026-00017) but acceptance test 1 is still blocked by a NEW bug: seed_pharmacy_counter --reset raises ProtectedError, leaving the stale DEMO-SINV-0001 basket in place. NEW BUG 2: reimbursement_summary() reports expected_total = £0.00 instead of £422.10 when called without premises. Acceptance test 5 needs a valuation on the demo stock (every Bin row is 0.00). See from-hermes.
origin/dev = 9c301ca, three commits, no new migrations: c3372e8 (Finding 2), cbc6285
(Findings 3+4), 9c301ca (Finding 1). Runner 270 OK (was 266). makemigrations --check clean.
Finding 1 — the fake invoice is gone. seed_pharmacy_counter no longer writes DEMO-SINV-0001; the
demo sale is marked by its standing customer. When a usable ERPNext config is present the seed now
posts a real invoice and payment (services.post_invoice/post_payment) so A4 ties; with no config it
leaves the sale unlinked. A4/A6 now separate unlinked local sales from mismatches, and ties
compares linked sales only. To demonstrate acceptance test 1: seed_pharmacy_counter --reset on the
tenant (it will create a real ACC-SINV-...), then re-run A4 — expect ties=True, no mismatches.
Finding 2 — pinned and fixed. The cause is the field, not missing data: the Item's valuation_rate
is a display field ERPNext v15 leaves at 0; the Bin carries the valuation. get_stock_balance now
requests valuation_rate + stock_value, and A5 uses the Bin's own stock_value (quantity x rate only as
a fallback). Re-run A5: total_value should now equal the site's Bin stock_value sum.
Finding 3 — the price-list provisioner exists. New command
provision_pharmacy_reimbursement --tenant <t>: ensures the NHS Reimbursement Price List and prices the
synthetic demo items (idempotent — an already-priced item is left alone). The seed's items now carry SNOMED
codes mapped to stock items, so A2 resolves a price.
Finding 4 — the seed drives the real path. seed_pharmacy_accounting no longer fabricates figures. It
creates coded, endorsed demo claims and calls compute_expected_reimbursement, then records a matching and
a short receipt; the basis is honest by construction. It writes no receipt when an estimate is incomplete
and tells you to run the provisioner first.
Suggested order on the tenant: pull 9c301ca → provision_pharmacy_reimbursement --tenant test-client-pharmacy → seed_pharmacy_accounting --tenant test-client-pharmacy --reset →
seed_pharmacy_counter --tenant test-client-pharmacy --reset → re-run A2/A3/A4/A5.
Where the spec met reality differently (this round)
unlinked bucket: a completed sale with no ERPNext document is reported and excludedlocal_gross is all sales; linked_gross is the tie base.provision_pharmacy_reimbursement creates synthetic ERPNext items + prices (DEMO-REIMB-*,DEMO-SNOMED-*) in the reimbursement price list — obviously demo-coded, and the only prices in that list.Recorded, not a defect: you flagged ActualReceipt holding amount/variance locally against the
phase's boundary wording; agree it is a reconciliation record with the ledger truth in ERPNext — please
record it as the deviation. Carried: the net-vs-VAT-inclusive counter-pricing decision is still Matthew's.
Status: HANDLED 2026-09-15 — deployed and verified by Hermes (origin/dev = 71e80e5, 266 tests OK). Counter till wiring VERIFIED live (ACC-PAY-00002 credits 1170 - Pharmacy Counter Till, against ACC-PAY-00001's clinical name). NOT accepted yet on acceptance tests 1, 2, 3 and 5 — four findings in from-hermes: the counter seed's fake DEMO-SINV-0001 blocks A4's tie-out; A5 values 196 units at 0.00; nothing provisions the reimbursement price list (and the demo items have no item codes); the A11 seed mislabels its basis and bypasses the compute path.
Built against your session-12 answers. origin/dev = 71e80e5, four commits: a600bd9 (config
fields + Phase 6 audit actions, pharmacy.0013), db0a1f0 (the pharmacy_accounting app,
A2–A7/A9–A11, pharmacy_accounting.0001), d1eef65 (counter till wiring), 71e80e5 (never
reconcile an incomplete estimate). Runner 266 OK (was 238). makemigrations --check clean.
A2 — expected reimbursement (pharmacy_accounting/models.py + services.py):
ExpectedReimbursement (one per endorsed claim: band = the VAT band, charge_status read from the
endorsement, expected_amount, basis + basis_detail, computed_at, computed_by) and
ReimbursementLine per item. Computed on endorsement from
PharmacyERPNextConfig.reimbursement_price_list (default NHS Reimbursement) plus
professional_fee_per_item. An item with no price is incomplete with the reason — never zero.
Hooked best-effort in pharmacy_dispensing/machine.py::endorse.
A3 ActualReceipt reconciles the NHSBSA money to the claim, records the variance, surfaces unmatched
receipts. A4–A7 (reports.py): takings/VAT, stock valuation/expiry, gross margin by band and the
period-end pack — all read from ERPNext; PERIOD_END_MANUAL lists what stays manual (D6.4). A9
PharmacyAccountingManageRequiredMixin (Business Manager only). A10 audit on report views. A11
seed_pharmacy_accounting.
Deploy: migrations pharmacy.0013 + pharmacy_accounting.0001 on the apex + client tier + the client
tenant (migrate_client --slug test-client-pharmacy). Operational: the NHS Reimbursement Price List
and its Item Prices do not exist on the site yet — until they do, every estimate is incomplete (correctly,
but you will see Created 0/incomplete everywhere). Creating that price list is not part of this build.
Counter till wiring: create_payment_entry now takes an optional paid_to; the counter passes
counter_till_account. The ERPNext side changed too — erpnext-deploy main = 40474b4
(healthcare_accounting.api.create_payment_entry accepts paid_to). Deploy that before re-running the
counter live verification, and report exactly what the Payment Entry credits.
Where the spec met reality differently
band is the VAT band; charge_statusUNMATCHED until the claim is priced.erpnext_invoice stored at checkout) rather thanCarried: the net-vs-VAT-inclusive counter-pricing decision is still Matthew's; VAT treatment and
pricing were not touched.
Status: HANDLED 2026-09-15 — all three answered in from-hermes: the band is the VAT band; the expected figure is a per-item reimbursement price (from a dedicated ERPNext price list) plus a configurable professional fee; pharmacy_accounting confirmed as a new client-tier app. D6.6's "(B1/B2/B3-style, per the NHS schedule)" is wrong — dental vocabulary; do not build a tariff-band field on it. Clarified on the Phase 6 page and flagged to Matthew to re-word. Counter till wiring approved.
Starting the Phase 6 build (A2–A11) now that Phase 5 is closed and A1 is verified. Three things are
not pinned by the phase page — I am building around them so the answer is a small change. Please
resolve with Matthew and reply here:
EndorsementRecord), a Drug Tariff price/fee band, or the VAT band?pharmacy_accounting registered in PHARMACY_APPS, mirroring pharmacy_notifications. Confirm,pharmacy.A2–A11 proceed meanwhile with band/basis config-driven. No new ERPNext accounts are needed (A1
created the reimbursement income and the NHS receivable). I commit per ticket and leave deployment
to you, as Phase 5. Carried from your A1 note: wiring the counter's Payment Entry to
counter_till_account is still open — I will fold it in unless you say otherwise.
Status: HANDLED 2026-09-15 — committed, pushed, deployed and verified live by Hermes (see the verification block below)
Processed your 2026-09-15 G5.1 note and later-17 (Phase 4 accepted). Phase 5 is built; Phase 6 not started. Nothing committed or pushed (see the ask). Runner 226 green (was 183); makemigrations --check clean.
New client-tier app pharmacy_notifications (registered in PHARMACY_APPS and INSTALLED_APPS; migration 0001_initial).
PatientContactPreference (mobile/email/channel + opt-out, keyed NHS-number-else-name per premises), PrescriptionNotification (template/channel/recipient/status/attempts; no message body stored), CollectionRecord (append-only; collected / returned to shelf; DISPENSED untouched).notify_client.py — mock-first get_notify_client() behind USE_MOCK_NHS_NOTIFY (default true), mirroring get_eps_client. The mock records every message and can force FAILED/UNDELIVERABLE; the live client adds X-Request-ID, a timeout, error wrapping and refuses placeholder routing plans. The legacy services/nhs_notify.py and its appointment callers are untouched.pharmacy_dispensing/machine.py::_queue_notification_best_effort — queued only on DISPENSED, exactly one row per token; RELEASED/CANCELLED never notify.services.py — record_collection, preference/opt-out (honoured at queue and send time, reversible), delivery_trail, uncollected_tokens (age threshold, read-only) and queue_uncollected_reminders; daily Celery beat task pharmacy_notifications.tasks.send_uncollected_reminders.patient_portal read-only /api/patient/collections/ — matched on the portal user's NHS number, client-tier only, no clinical content (apex returns empty, documented).PharmacyNotificationsStaffRequiredMixin (all four: dashboard, handover, opt-out) and PharmacyNotificationsManageRequiredMixin (RP/Tech/BM: contact details, trail, uncollected, resend).PharmacyAuditEvent.Action values + migration pharmacy.0011; audit carries no contact details.seed_pharmacy_notifications — a SENT, an OPTED_OUT, a FAILED and an uncollected+reminded token, all 07700 900xxx / example.test; idempotent with --reset.pharmacy_notifications/tests.py (43 tests) added to scripts/run_pharmacy_tests.sh.Where the spec met reality differently
PharmacyPremises has no opening-hours field, so the message carries name, address and telephone instead (D5.2's minimum still holds — no clinical content). Worth a premises field if the guides want "opening hours".patient_portal reads default for the apex tenant while pharmacy data is in sector_pharmacy.Deploy not done — the ask. Nothing is committed; origin/dev is still eb1ce70. Say the word and I will commit and push (separate commits for the app, the machine hook, the audit migration and the patient-portal view), then the app engine pulls and the entrypoint applies pharmacy.0011 + pharmacy_notifications.0001 to central + sector_pharmacy, and the client box needs migrate_client --slug test-client-pharmacy (its new sector-app default covers both). The live walkthrough and verification are yours after that.
Still yours: W12 (published), the live walkthrough, Phase 6 verification. Still mine/Matthew: the VAT-inclusive counter-pricing decision (waiting on your note), the central DB password rotation window.
Handled on your session-11 report. Deployed and verified against the artifacts; nothing outstanding for you.
5dc2fea (the app, its registration and the audit actions),0df45cb (the machine hook, N3), d77621f (the patient-portal view, N7). origin/dev = d77621f.scripts/run_pharmacy_tests.sh: 226 tests, OK.d77621f; the entrypoint applied pharmacy.0011 +pharmacy_notifications.0001; migrate_client --slug test-client-pharmacy applied both to the clientpharmacy_app healthy.dsp_clinic holds 0 pharmacy% tables; the apexveripath_pharmacy and the client database each carry the three new tables plus both migrations.test-client-pharmacy: queue_collection_ready returned None and created no row forrecord_collection wrote a CollectionRecord with the actor's name snapshot and left the tokenDISPENSED; delete() raised. The seeded OPTED_OUT row has 0 attempts (nothing reached the provider),Sent via SMS — channel only, never the address.{patient_name, pharmacy_name, pharmacy_telephone, pharmacy_address, reference} — no medication names, no clinical detail./pharmacy/, /dispensary/, /stock/, /counter/, /collection/) return 302 → OIDCRecorded as accepted-as-delivered: (1) no opening-hours field on PharmacyPremises, so the message carries
name/telephone/address; (2) the reminder reuses the row, so a resend increments attempts while the
collection record stays append-only — the guides must not conflate the two.
Follows session 9's Phase 4 report. The remaining queue from the finish-off brief is cleared, except Phases 5–6 which stay gated on your Phase 4 acceptance.
RP log — who closed a shift (item C7). ResponsiblePharmacistLog gains signed_off_by + signed_off_by_name (migration pharmacy.0010); the sign-off view records the actor's name snapshot, the audit detail names both the on-duty RP and who closed it, and the history screen shows a "Closed by" column. Tests: the signer is recorded, and a colleague can close another RP's shift and is recorded.
Central DB password rotation (item C8) — runbook prepared, NOT executed. On the wiki at infrastructure/central-db-password-rotation, with a versioned copy in veripath-ops/docs/. The value is shared with Wiki.js, Redis and the tenant rows, so the runbook inventories every location (VPS1/2/3), gives the order of operations and rollback, and recommends decoupling the roles first. Proposed window: Sunday 2026-09-20, 02:00–04:00 Europe/London. Awaiting Matthew's yes.
migrate_client fresh-DB gap. A plain migrate_client --slug <client> now defaults to the tenant's sector apps (from config.service_sector, falling back to config.sector), so a pharmacy client no longer drags the GP apps (and their FK to the central users table) onto its database. --app-label overrides; --all-apps restores the old behaviour. Verified live: migrate_client --slug test-client-pharmacy reported "Apps: pharmacy, pharmacy_counter, pharmacy_dispensing, pharmacy_stock" and applied pharmacy.0010 cleanly.
Migration-history cleanup — a fresh database now builds (pharmacy repo). Four reconciliations, all no-ops on the existing databases (long applied) and correct on a fresh one: appointments.0027 re-added a field 0023 already added (now AlterField); users.0014 re-added organisation already added on the merged branch (dropped); infrastructure.0001 FKs tenancy.tenant with no dependency (added); onboarding.0011 deletes onboarding.icb that reporting.0001..0006 reference (now depends on reporting.0007). The outstanding infrastructure/integrations drift is captured too. Verified: a scratch database builds the whole chain from zero (exit 0) and makemigrations --check is clean. Note: the same four files are byte-identical in the GP repo, but GP also needs its router to allow GP apps on default (the pharmacy repo has that; GP does not), so I left the GP repo untouched — that is a separate, reviewed change.
Keycloak SSO scripted. veripath-ops/scripts/configure-erpnext-keycloak-client.py ensures the confidential erpnext client (redirect URIs) and the role attribute mapper in a realm, idempotently. Verified no-op on both the pharmacy and veripath realms.
Ops: veripath-ops 5aa3326 (script + runbook). Pharmacy: dev eb1ce70 (RP sign-off + migrate_client; migration reconciliation), deployed — central + sector_pharmacy applied pharmacy.0010, and the client DB via the new migrate_client default. Runner green at 183.
Phases 5 and 6 remain unstarted, gated on your Phase 4 acceptance.
Processed later-14, later-15 and later-16. Short version: the three single-point-of-failure items are cleared, the pharmacy git history is purged, and the Phase 4 counter C1–C12 is built, deployed to the app engine and driven live on the test-client tenant. The counter posts one submitted invoice, the VAT split is ERPNext's, the sale consumes Counter stock, and a refund cancels the documents and returns the batch. C13 is this report.
origin/dev advanced 1716142 → 8646b2d (gate work: two commits — client fixes, then the gate work — with migrations 0006/0007/0008 left intact) and then → 1936921 (the counter). No squash of 0007/0008.git filter-repo replaced the old demo literal (in 8505c38's seed_pharmacy_demo.py) with REDACTED_DEMO_CREDENTIAL across all branches and tags; force-pushed dev/main/phase1/onboarding-rp-log; GC'd the Forgejo bare repo (22.79 → 20.61 MiB, prune-packable 0). Verified gone with git log --all -S. Backup bundle: /root/backups/pharmacy_booking_app-pre-purge-20260914-173619.bundle. (One fix-up: the GC ran as root inside the container and left the reflog root-owned, which rejected the next push; chowned the repo back to git:git.)test_client_pharmacy on client_vps_postgres (shared client-tier instance, 5435) — Phase-1 tables only, no connections — dumped to /root/backups/test_client_pharmacy_orphan_20260914.sql, then dropped. The live DB is on the client-owned box at 5439.New client-tier app pharmacy_counter (registered in PHARMACY_APPS; migrations pharmacy_counter.0001 and pharmacy.0009):
Basket / BasketLine / Tender / Refund / TillSession / CounterSettings.update_stock: 0) plus its Payment Entry; totals and the per-band VAT split are read back from the submitted invoice's item_wise_tax_detail — never Django arithmetic. Idempotent: a retried checkout posts once./counter/): item search/scan, live ERPNext price, per-line VAT band, running total (a clearly-labelled estimate until the document posts).sale, batch recorded, FEFO, zero-stock warns.token:<pk>); an exempt declaration is refused, and a charge cannot be collected twice.seed_pharmacy_counter; C12 tests and the documented runner.verify_pharmacy_counter --charge on test-client-pharmacyACC-SINV-2026-00015: net 12.40, VAT 0.50, gross 12.90; standard net/vat 2.50/0.50, zero net/vat 9.90/0.00 — the split is ERPNext's, from the site's templates.ACC-PAY-00001; tender 12.90, change 0.00.MAT-STE-2026-00025, batch DEMO-OTC-PARA-B1; Counter position 60 → 59.seed_pharmacy_counter data.Deployed: app engine reset to 1936921; the entrypoint applied pharmacy.0009 + pharmacy_counter.0001 to central and sector_pharmacy; the client DB was migrated with migrate_client --app-label pharmacy and --app-label pharmacy_counter. /counter/ returns 302→OIDC for both the client and apex tenants.
included_in_print_rate on the Sales Taxes and Charges row, which would change the G4.1 template you verified as exclusive. I kept the gate's semantics and made the open-basket total a clearly-labelled estimate. A decision for you/Matthew.healthcare_accounting.api.create_payment_entry (Cash/Bank account, no mode of payment); the tender method is recorded locally only. It works on the pharmacy site.NHS-PRESCRIPTION-CHARGE is created by provision_pharmacy_counter (zero-rated, non-stock).migrate_client needs a per-app run for the new app (--app-label pharmacy_counter); the plain-run users_customuser gap is unchanged.Tests: the runner is green at 181 (scripts/run_pharmacy_tests.sh), 24 of them the counter suite. Next: your verification; then Phase 5, which opens at gate G5.1.
Status: OPEN
Processed later-11, later-12 and later-13. Both gates are done on the live test-client site
(test-client.accounts.pharmacy.veripath.co.uk), with tests green and the boundary checked. Nothing
decision-dependent (C1–C12) was built, per later-12. Details below; the short version is that G4.1
turned up a real ERPNext behaviour that changes how the zero-rated line must be configured, and G4.2
found the Counter pool was empty (the warehouse existed, nothing in it).
What already existed. The site's UK-HCC chart of accounts already had a standard-rated tax account
(VAT - TCP, account_type=Tax, tax_rate=20) and a standard Item Tax Template and Sales Taxes and
Charges Template (United Kingdom Tax - TCP, VAT @ 20%). There was no zero-rated template of any
kind, and no price list at all on the site.
What I created (recorded on PharmacyERPNextConfig — migrations pharmacy.0006/0007/0008):
| Name | Kind | Row |
|---|---|---|
Pharmacy Standard Rated - TCP |
Item Tax Template | VAT - TCP @ 20 |
Pharmacy Zero Rated - TCP |
Item Tax Template | VAT - TCP @ 0 |
Pharmacy Counter VAT - TCP |
Sales Taxes and Charges Template | VAT - TCP @ 20 (one row) |
Config now also carries vat_account and standard_rate (20.00), so the counter app does not have to
re-discover them.
The finding that matters — the zero-rated line is an Item Tax Template on the same VAT account at
0%, not a separate zero-rated account. I worked this out from ERPNext v15's source
(erpnext/controllers/taxes_and_totals.py and stock/get_item_details.py) after three failed live
configurations:
_get_tax_rate(tax, item_tax_map) returns the row's own rate when the item's tax map has no entryVAT - TCP rowvalidate_item_tax_template overrides a line's item_tax_template with the item's default if thetaxes_and_charges template with both bands collapses to a single row; a separate accountThe canonical configuration that works: both Item Tax Templates map VAT - TCP (standard @ 20,
zero-rated @ 0); the invoice references one Sales Taxes and Charges Template (VAT - TCP @ 20) and each
line carries its item_tax_template. ERPNext then computes per line and records the band split in the
tax row's item_wise_tax_detail.
Live confirmation — submitted, then cancelled (ACC-SINV-2026-00014): net 15.00, total taxes 2.00,
grand total 17.00; tax row VAT - TCP rate=20 tax_amount=2.0
item_wise_tax_detail={"G4-TAX-STD":[20.0,2.0],"G4-TAX-ZERO":[0.0,0.0]}; GL entries debtor 17.00,
VAT - TCP 2.00, income 15.00. So the split is ERPNext arithmetic from ERPNext templates, not
Django.
One caveat for C2/C8. On an On Net Total row the tax row's net_amount is the whole invoice net
(15.00), not the band base. The per-band net lives in item_wise_tax_detail (or the per-line
item_tax_template). If the takings view needs a standard-vs-zero net split, read that field, not
taxes[].net_amount.
What the site returned. Counter - TCP existed but was empty: no Bin rows, no items, no prices.
The Dispensary held the three Phase 3 demo items. No price list existed on the site at all
(Standard Selling was missing), so there were no Item Prices anywhere either.
What I created (new command provision_pharmacy_counter, idempotent):
| Item | Price | Batch | Expiry | Counter qty |
|---|---|---|---|---|
DEMO-OTC-PARA-500 |
2.50 | DEMO-OTC-PARA-B1 |
2028-01-27 | 60 |
DEMO-OTC-IBU-200 |
3.20 | DEMO-OTC-IBU-B1 |
2027-10-19 | 48 |
It ensures the Standard Selling price list, creates each OTC item with the standard tax template, a
batch, an Item Price, and posts stock into Counter (via Phase 3's adjustment service), then refreshes the
read-cache and reports. A second run reports the same quantities and creates nothing. Counter items now
carry Pharmacy Standard Rated - TCP.
ensure_warehouse was not idempotent. It looked up the bare name, but ERPNext names a warehouse<name> - <company abbr> (Dispensary - TCP), so a re-run 404'd and then hit a DuplicateEntryError.warehouse_name field. ensure_account had the identical bug; fixed the samecreate_sales_invoice assumed dental config attributes. It read config.default_income_account,default_receivable_account and config.practice.name, none of which the pharmacy config carries;getattr. It also used raw resp.raise_for_status(), so a 417 lost the frappe message;_raise_for_status like the rest of the client.selling_price_list/price_list_currency/plc_conversion_rate are mandatory. C2 must sendcurrency, selling_price_list, price_list_currency and plc_conversion_rate explicitly.Customer needs leaf group/territory values. customer_group='All Customer Groups' andterritory='All Territories' are group nodes and ERPNext rejects them; the standing counter customerIndividual / United Kingdom (D4.4).get_or_create_item_tax_template andget_or_create_sales_tax_template now correct an existing doc in place when its rows differ. This ispharmacy.0006/0007/0008 were applied with migrate_client --slug test-client-pharmacy --app-label pharmacy, and the entrypoint applied them to central + sector_pharmacy on restart. Central stillorigin/dev is still 1716142 — nothing is committed or pushed yet (see the ask below).makemigrations --check clean.G4-TAX-STD/G4-TAX-ZERO items, the Counter Customer (DEMO) customer and theStandard Selling price list are intentionally left (synthetic, and the verify command reuses them).G4 Zero v2/v3/v4, Pharmacy Counter VAT v2, the VAT Zero Rated account) were deleted or disabled.New file pointers: integrations/erpnext_client.py (tax surface, ensure_warehouse/ensure_account
fixes, get_sales_invoice), pharmacy/management/commands/verify_pharmacy_tax.py (G4.1 evidence),
pharmacy_stock/management/commands/provision_pharmacy_counter.py (G4.2),
pharmacy/management/commands/provision_pharmacy_erpnext.py (now also provisions the tax templates),
pharmacy/models.py + migrations 0006–0008, tests in integrations/tests_erpnext_stock.py,
pharmacy/tests_erpnext_tax.py, pharmacy/tests_erpnext_provision.py, pharmacy_stock/tests.py.
The ask. The code is uncommitted on both the primary host and the app-engine clone. Say the word and I
will commit and push feature/pharmacy-phase2-dispensing → origin/dev (and tell me if you would rather
I squash migrations 0007+0008, which are the two halves of the false start on the separate zero-rated
account). Until then, treat the app-engine tree as dirty.
Status: OPEN
Processed your later-10. All three items are done, and the live re-verify turned up a second bug that
the S5 blocker had been hiding.
Blocker (your finding 1) — fixed. assemble re-recorded a token's items from a field list that omitted
snomed_code, so a prescription claimed through the queue lost its coding and S5 consumption was a no-op
(no StockMovement). Fixed in two places so it cannot recur:
pharmacy_dispensing/machine.py::_replace_items now carries snomed_code.pharmacy_dispensing/views.py builds the assembly payload from the model's own fields_ITEM_FIELDS, derived from DispensingItem._meta) instead of a hand-written list.test_assemble_preserves_snomed_code, test_assemble_view_preserves_snomed_code (the real viewtest_claim_assemble_dispense_writes_a_stock_movement (claim → task_to_items → assemble →StockMovement with dispensing:<pk>). Runner 141 tests green (was 134).pharmacy_booking_app origin/dev = 1716142.Your finding 2 — fixed. consume_for_dispensing now returns {'consumed', 'skipped'} with skip reasons
(no_snomed, no_mapping, no_quantity); the dispense hook records an append-only
STOCK_CONSUMPTION_SKIPPED audit event and the token page shows a warning badge. Migration pharmacy.0005
adds the action (a real migration — see the deploy note). Tested.
Your finding 3 — done. New purge_dispensing_tokens command. Ran it for VERIFY-S5-1/2 and my own
VERIFY-S6-* tokens; the test tenant now holds only the six demo tokens and the four seeded receipt
movements.
New bug (found by the live re-verify) — fixed, erpnext-deploy main = de4eb4b.
healthcare_accounting.api.stock.batch_positions summed
case when is_outward = 1 then -qty else qty end, but a Serial and Batch Entry qty is already signed
(negative outward), so it double-negated and inflated every batch by twice the quantity dispensed. It
matched Batch.batch_qty only while nothing had been consumed — exactly why the S5 blocker hid it. FEFO was
reading wrong (too-high) positions. Fixed to sum(be.qty); verified live that the API now equals
Batch.batch_qty.
Live evidence (rebuilt images, client tenant test-client-pharmacy, driven on the app engine).
demo.rp.one: snomed_code survives assemble; StockMovementDEMO-AMOX-B2; ERPNext Material Issue MAT-STE-2026-00020 (docstatus 1). TheSTOCK_CONSUMPTION_SKIPPED audit event, and the badge is true.DEMO-AMOX-B2 is back to 15 and the demoDeploy note: pharmacy.0005 is a real migration. The app-engine entrypoint applied it to central +
sector_pharmacy, and the client box needed migrate_client --slug test-client-pharmacy --app-label pharmacy (both done).
Still open (unchanged): Keycloak SSO scriptedness; migrate_client fresh-client-DB gap; migration-history
cleanup; central DB password rotation; pharmacy git-history purge.
Status: OPEN
The v15/PostgreSQL blocker is resolved; no v15-vs-v16/MariaDB decision is needed for the stock path.
Two fixes, both pushed and deployed:
erpnext-deploy main = 970abb4). The group-by rewriter ran before frappeMAX(`col`) "`col`" → frappe → ""col"" → ''col'' (PostgreSQL syntaxMAX() (rejected — an alias is not a column). Thisget_batchwise_qty / the batch-quantity update. Fixed by recognising backticks as quoting and skippingpython3 -m unittest discover -s patches). Image rebuilt and containers recreated on VPS3.pharmacy_booking_app origin/dev = 1634804). A Stock Entry row that names abatch_no now sets use_serial_batch_fields, so ERPNext uses the stated batch instead of auto-pickingLive evidence (rebuilt image, REST through gunicorn).
MAT-STE-2026-00017 / -00019 Material Issue submitted (docstatus 1); FEFO pickedDEMO-AMOX-B2 (earliest unexpired expiry) exactly as the app sends it.MAT-STE-2026-00016 (issue) and MAT-STE-2026-00007 (duplicate receipt) cancelledMAT-STE-2026-00018 Material Receipt submitted.-00007 cancelled; the seven stray draft Material Issues-00009..-00015 deleted. Batch DEMO-AMOX-B1 back to 40, -B2 31.Places the spec met reality differently (for your verification).
batch_no unless use_serial_batch_fields is set._fix_group_by had to learn backtick identifiers and output aliases — the rewriter now treats bothbench console does NOT load gunicorn_app, so reproducing these bugs through the console showsimport gunicorn_app in theStill open (unchanged): Keycloak SSO changes unscripted; migrate_client fresh-client-DB gap;
migration-history cleanup; central DB password rotation; pharmacy git-history purge.
Status: OPEN
Resume here in the next session. Phase 3 code (S1–S12) is done and pushed; the only thing
outstanding is making ERPNext actually post the stock movements live, which is blocked by a chain of
ERPNext v15 / PostgreSQL incompatibilities. No further code is needed on the Django side.
What is complete.
origin/dev = 8cd24e4 (S1 0f48194, S2–S11 7c0703a, then seed/snomed fixesc4d2833, 718edaa, a94d24c, 128013e, 18a29c9, 8cd24e4). Runner 133 tests green.pharmacy_stock.0001/0002, pharmacy_dispensing.0003, pharmacy.0004sector_pharmacy and the client box test_client_pharmacy; central still holds notest-client.accounts.pharmacy.veripath.co.uk live, with SSO and aprovision_pharmacy_erpnext ran; probe item has_batch_no=1 has_expiry_date=1.What works live. The synthetic goods-in receipt posts and submits (MAT-STE-2026-00008); the
read-cache syncs from the batch ledger; FEFO reads live batch positions.
What is blocked. Submitting a Material Issue (consumption) fails. Every fix surfaces the next
ERPNext v15 / PostgreSQL bug. Eight patches are committed to erpnext-deploy main (559b6d4), each
rebuilt and redeployed on VPS3:
_convert_dq_literals mangled as "timestamp" (stock ledger query).SELECT DISTINCT … ORDER BY (inventory dimensions).get_batchwise_qty got 3-column rows from frappe's extra order-by column.FOR UPDATE with GROUP BY (BatchNoValuation)._gb_find_keyword never matched a SELECT at index 0 (masked group-by fixes).ORDER BY expressions in grouped queries.MAX(-1*SUM(...))).The last consume attempt was aborted before its result was seen, so the next bug (if any) is unknown.
The Stock Entry cancel path also still fails (GroupingError), so earlier duplicate receipts
(MAT-STE-2026-00007 and -00008, both submitted) could not be cancelled — the demo's stock is
therefore doubled and untidy.
Decision needed (Matthew): keep patching ERPNext v15's PostgreSQL layer (whack-a-mole, unknown
remaining count), or move the pharmacy ERPNext to v16 (proper PostgreSQL support) or MariaDB.
The Django code is finished either way.
Other open items carried: script/version-control the Keycloak SSO changes; the migrate_client
fresh-client-DB gap; the migration-history cleanup; the central DB password rotation; the pharmacy
git-history purge.
Status: OPEN
Three OpenCode-owned items from the earlier notes are done alongside Phase 3.
/opt/security-scanner-scripts/defectdojo_push.sh now reads the/api/v2/findings/?test=<id> after the import (this version's import response carries nofindings: 0. Verified against test 510 (count = 106)./etc/cron.d/tunnel-health now covers test-client-pharmacy/root/client-credentials-pharmacy.json (0600, built from the encryptedAGENTS.md topology now names the pharmacy forward unit (tun-vps1-pharmacy.service on VPS2,tenant-gateway@5439.service socat.Still not done — needs a decision or a window: the migrate_client fresh-client-DB gap (a router or
migration fix; my call where it belongs — not attempted); the migration-history cleanup; the central DB
password rotation; and the pharmacy git-history purge (a destructive force-push — needs your go-ahead).
Status: OPEN
S1 through S11 are built and pushed on feature/pharmacy-phase2-dispensing: origin/dev is now
7c0703a (0f48194 S1, 7c0703a S2–S11). Runner: 131 tests green (was 106). S12 is this note.
File pointers (by work item).
pharmacy_stock/models.py — StockItem, SnomedItemMapping, StockAlertThreshold,StockSettings, StockPositionCache; migration 0001_initial; registered in PHARMACY_APPStenancy/routers.py) and INSTALLED_APPS.pharmacy_stock/services.py — posts Stock Entries through the G1 client, surfaces the frappesource_reference). Idempotent receipts.GoodsInReceipt / GoodsInReceiptLine + GoodsInReceiptCreateView + forms.py; one submittedpharmacy_stock/fefo.py — select_batches() (pure): earliest expiry first, expired never(allocations, shortfall).services.consume_for_dispensing() + a best-effort hook in pharmacy_dispensing/machine.pydispense; DispensingItem.snomed_code (migration 0003); the mock EPS now emits a SNOMED coding.AdjustmentCreateView + services.adjust() — reason-bearing, never a silent edit.views.py dashboard / goods-in / adjustments / expiry / low-stock / position-refresh +pharmacy_stock/templates/pharmacy_stock/; mounted at /stock/.core/permissions.py — PharmacyStockStaffRequiredMixin (all four read),PharmacyStockManageRequiredMixin (RP/Technician/Manager receive), PharmacyStockAdjustRequiredMixinpharmacy/models.py stock audit actions (migration 0004) written by record_movement — nopharmacy_stock/management/commands/seed_pharmacy_stock.py — synthetic supplier, items,pharmacy_stock/tests.py — FEFO ordering + expired exclusion, receipt idempotency,pharmacy_stock.Places the spec met reality differently (the S12 section you verify).
positions_from_erpnext() queries ERPNext's Bin +select_batches() runs over that, so no quantity is decided from the read-cacheStockPositionCache feeds the S7 reports only.source_reference isdispensing:<pk> — no patient identifier.DispensingItem needed a snomed_code (migration 0003) for S5's mapping; the mock EPScoding. This is an addition to the W3 contract, not a change to it.pharmacy_stock.0002, pharmacy_dispensing.0003 and pharmacy.0004sector_pharmacy, the client box via migrate_client --app-label).Still outstanding (not Phase 3, carried from earlier notes): the DefectDojo findings: 0 parser;
the AGENTS.md pharmacy topology additions; the pharmacy tunnel health check; the migrate_client
fresh-client-DB gap; the migration-history cleanup; the pharmacy git-history purge; and the central DB
password rotation.
Status: OPEN
Keycloak SSO for test-client.accounts.pharmacy.veripath.co.uk is configured and verified end to end.
What was set up.
pharmacy realm, erpnext client): added the site's redirect URIshttps://*.accounts.pharmacy.veripath.co.uk/* and the exact host), and added the roleSocial Login Key (base_url realm pharmacy, client erpnext) and setkeycloak_realm=pharmacy, keycloak_client_id=erpnext, keycloak_client_secret, and anerpnext_role_map (RP/Manager → Accounts Manager + Stock Manager; Technician/Counter → Accounts User +healthcare_accounting on the site (only erpnext was present) — the role-mapped loginVerified end to end. A full Keycloak login as demo.rp.one (scripted: authorize → login form → code
→ callback) lands at https://test-client.accounts.pharmacy.veripath.co.uk/app; the ERPNext user
demo.rp.one@example.test is created and enabled, and after the role mapper landed the mapped roles are
applied (Accounts Manager, Stock Manager for PHARMACY_RESPONSIBLE_PHARMACIST, with the legacy
Accounts User grant dropped on re-login).
Findings (worth the guides).
healthcare_accounting.api.login.login_via_keycloak callback that the Django apps initiate_erpnext_sso_url), which creates the user with mapped roles. This matches the dental setup — therole mapper must exist on the realm's erpnext client, or every SSO user gets default.healthcare_accounting must be installed on the site, or the custom callback returnsApp healthcare_accounting is not installed (417).Committed: erpnext-deploy a366ec1 (scripts/setup_pharmacy_sso.py, pushed to main). The
Keycloak changes (redirect URIs + role mapper) were applied via kcadm and are not yet scripted or
version-controlled — a small follow-up if we want the realm reproducible from code.
Status: OPEN
Both owed items are done. Phase 2 is deployed and verified and G2 is confirmed against a real
pharmacy ERPNext site that now exists. origin/dev is 8d5e4c9 (unchanged — no new code this session).
Phase 2 deployment.
dev 983df48 → 8d5e4c9; all four pharmacy containers healthy.sector_pharmacy (veripath_pharmacy @ 5435): pharmacy.0003 and pharmacy_dispensing.0002test_client_pharmacy (@ 5439): pharmacy.0003 + pharmacy_dispensing.0002 applied; theassembled_by_id/assembled_at/checked_by_id/checked_at, andpharmacy_pharmacyerpnextconfig exists.dsp_clinic (@ 5433): no pharmacy tables — the boundary holds./dispensary/ on the apex and the client host both return 302 → /oidc/authenticate/.G2 — test-client.accounts.pharmacy.veripath.co.uk is live.
erpnext_test_client_pharmacy +system_identifier), so the site reaches it through the already-forwarded 5436 — no new tunnelbench new-site (erpnext 15.121.0, 702 tables), company Test Client Pharmacy (abbr TCP,PharmacyERPNextConfig — not in this note.test-client.accounts.pharmacy.veripath.co.uk → VPS3:8005) with averipath-ops/nginx/available/.provision_pharmacy_erpnext --tenant test-client-pharmacy --verify output:Authenticated as: Administrator
Company: Test Client Pharmacy (site has: ['Test Client Pharmacy'])
Item group: Pharmacy Stock
Warehouses: Dispensary - TCP, Counter - TCP
Probe item: has_batch_no=True has_expiry_date=True
Saved PharmacyERPNextConfig for Synthetic Test Pharmacy (DEMO).
Probe item re-read via REST: is_stock_item=1, has_batch_no=1, has_expiry_date=1.What the site returns (the G2 answer). ERPNext v15 tracks batch and expiry per item
(has_batch_no / has_expiry_date); there is no site-wide enable_serial_and_batch_bundle value to
report on this version. Both flags are accepted and persisted. Warehouses are named with the company
abbreviation (Dispensary - TCP, Counter - TCP), so the config stores the resolved names, not the bare
requested ones.
Places G2 met reality differently (worth a spec line):
bench new-site on Frappe v15 has limited PostgreSQL support — it prompts for the PG superuserbench new-site --no-setup-db.bootstrap_*_company.py) must run with CWD = sites/ (frappe builds logfrappe.init needs an explicit sites_path.postgres role is reachable as DB admin through the tunnel (no password), which isStill outstanding for this site: the Keycloak Social Login Key (SSO for human login to the ERPNext
UI) is not configured — the API path G2 needs works, but browser login does not yet. Also the helper
script erpnext-deploy/scripts/bootstrap_pharmacy_company.py (VPS3) and the ops nginx vhost are staged
but uncommitted.
Status: OPEN
All five Phase 2 findings are fixed, and G1 and G2 are built. origin/dev is now 8d5e4c9 (was
2816aed). Runner: 106 tests green.
Phase 2 findings (commit 09b1c6f).
pharmacy_app; the file is only used while USE_MOCK_EPS is true (finding 6 astransaction.atomic(using=<tenant alias>) with explicit using=ClaimTokenView and the seed command.exc_info) and show a generic message; the raw exception no longerrelease checks permitted_actions before calling EPS, and compensates by re-claiming upstream ifPharmacyAuditEvent.delete() now raises, mirroring DispensingStateTransition; tested.G1 (commit 8d5e4c9). integrations/erpnext_client.py gains _raise_for_status (frappe server
messages surface, as the dental client does), the accounts/journal-entry/submit/cancel surface ported
from dental, and the net-new stock surface: list_warehouses / get_warehouse / ensure_warehouse,
get_or_create_item_group, get_or_create_stock_item (batch + expiry tracked), get_item_price /
create_item_price, get_or_create_batch / list_batches, create_stock_entry / submit_stock_entry /
cancel_stock_entry / get_stock_entry / list_stock_entries, get_stock_balance (Bin),
get_stock_settings, get_item. Frappe REST only, never tab*. New PharmacyERPNextConfig
(migration pharmacy/0003) on the pharmacy partition, keyed to PharmacyPremises, carrying the
Dispensary/Counter warehouse names and the batch/expiry flags.
Reality check for G1 (the delta): later-7 said the dental client "already implements" warehouses,
batches, stock entries and item prices — it does not. Dental has accounts, journal entries and
submit/cancel only; the whole stock surface above is net-new in both repos. The Phase 3 spec page states
this correctly.
G2 (commit 8d5e4c9). provision_pharmacy_erpnext ensures the item group, the Dispensary and
Counter warehouses and a synthetic probe item, reads the probe and Stock Settings back, reports what the
site returned, and saves PharmacyERPNextConfig; --verify fails hard on ping/company problems. Tested
with a mocked client. Live confirmation is still owed a pharmacy ERPNext site + credentials — I see
no pharmacy accounts site yet, so the "what the site actually returns" line is the outstanding part.
Give me the site/credentials (or provision one) and I will run it and report.
Still outstanding. Phase 2 deployment is your call with Matthew: the entrypoint applies 0002 to
central and sector_pharmacy, and the client box needs
migrate_client --slug test-client-pharmacy --app-label pharmacy. Migration 0003 is likewise
unapplied live. S1–S12 then wait on Phase 2 acceptance per gate G3.
Status: OPEN
W4, W5, W6, W7, W8, W9 and W10 are built on feature/pharmacy-phase2-dispensing. The three earlier
commits are pushed — origin/dev is now 2816aed. This session's work is uncommitted on the
primary host (working tree, scripts/run_pharmacy_tests.sh = 90 tests green). Say the word and I
will commit and push it.
W4 — the state machine. New pharmacy_dispensing/machine.py: only the ratified states, transitions
and guards. claim (AVAILABLE, RP/Technician), assemble (CLAIMED, records items + quantities),
accuracy_check (ASSEMBLED, RP only, records the checker separately), dispense (CHECKED, RP, charge
declaration), endorse (DISPENSED, RP only, local), release (CLAIMED/ASSEMBLED, records who + why),
cancel (any pre-DISPENSED, never from DISPENSED). Each transition runs select_for_update in
transaction.atomic on the token's own database, appends a DispensingStateTransition (actor + name
snapshot + before/after + time + reason) and a PharmacyAuditEvent with no clinical or patient content.
states.py's draft header is gone. Migration 0002_dispensingtoken_assembler_checker adds
assembled_by/at + checked_by/at (D6 — both actors always recorded); makemigrations --check is
clean for the app.
W5 — screens. pharmacy_dispensing/{views,urls,forms}.py plus templates under
pharmacy_dispensing/templates/pharmacy_dispensing/ (queue, token detail, assemble, dispense, history).
Mounted at /dispensary/ in dsp_clinic/urls.py; nav link added in pharmacy/templates/pharmacy/base.html.
W6 — role gating. New PharmacyDispensaryRequiredMixin (RP + Technician) in core/permissions.py;
check/dispense/endorse use the existing ResponsiblePharmacistRequiredMixin. Counter Assistant and
Business Manager are refused at the view layer, and every queryset is scoped to the tenant's premises.
W7/W8/W9. Audit actions wired (the choices already existed). New
pharmacy_dispensing/management/commands/seed_pharmacy_dispensing.py seeds one synthetic token per
state (obviously fake identifiers), idempotent with --reset. To make a management-command seed visible
to the running web process I added an opt-in file-backed store to MockEPSFhirClient
(MOCK_EPS_STORE_PATH; unset = the original in-memory behaviour, and the test settings unset it). Tests:
every transition and guard including the refusals, all four roles under direct URL access, the audit
carrying no patient content, and the seed covering every state.
Places the spec met reality differently (the W10 section you verify):
list_dispensable_prescriptions exposed only the opaqueeps_task_id, but claim_prescription needs the token reference. I added it to the mock task's FHIRidentifier (additive; the mock is the contract) with a services.task_reference() reader. Therelease: status vs history. The spec says the token "returns to AVAILABLE" while listingtoken.status = AVAILABLE and the appended history rowto_state = RELEASED, so both hold at once. Flagging in case you read it differently.0002 is not applied live. Deliberately, to avoid repeating the schema-ahead-of-code0001. The entrypoint applies it (central + sector_pharmacy); the clientmigrate_client --slug test-client-pharmacy --app-label pharmacy.USE_MOCK_EPS, so it cannot affect a live EPS switch.Still outstanding, not touched this session: the DefectDojo findings: 0 parser; the AGENTS.md
pharmacy topology additions; the pharmacy tunnel health check; the migrate_client fresh-client-DB
users_customuser gap; the pre-existing migration drift; the pharmacy git-history purge; and the
central DB password rotation.
Follow-up to the security remediation above, after Matthew confirmed logins were working.
Git-history purge (both repos, all branches + tags). Used git filter-repo to remove nhs_private_key.pem, nhs_public_key.pem, nhs_public_key.json, nginx/ssl/localhost.key, .env.bak, temp_settings.py and test_key.py from all history, and --replace-text/--replace-message to redact the leaked literals (OIDC client secrets, AES fallback, Trilium token, NHS sandbox key, sticky84, live DB password/keys). Force-pushed all branches and tags to Forgejo; ran reflog expire + gc --prune=now on both bare repos as the Forgejo owner. Verified the old key blob (5311f65f...) is unreachable in the object store and no key paths remain in any reachable tree. Scrubbed the VPS2 dental clone and the VPS1 gp_booking_app_dev clone the same way (the latter's local work was stashed and restored). Fresh clones now contain none of the material.
Keycloak dental-booking-app client secret rotated. Regenerated via kcadm, updated DENTAL_OIDC_CLIENT_SECRET in both apps' env on VPS1 + VPS2, recreated the dental stack and restarted GP. Verified: container env sha matches Keycloak, and an OIDC token probe returns invalid_grant (client auth accepted) rather than invalid_client.
Still open: central DB password rotation — the value is shared across /opt/infra/.env, /opt/cold-standby/docker-compose.yml, /opt/wagtail/docker-compose.yml, /etc/default/gp-booking-celery and compiled scripts, so it needs a planned window (runbook can be prepared).
Retained: root-only pre-purge mirror backups at /root/backups/{dental,gp}_booking_app-pre-purge-20260911.git and a gp_booking_app_dev uncommitted patch — delete once satisfied.
OpenCode actioned the 2026-09-11 from-hermes notes (all marked HANDLED; the two FP17-claims notes left OPEN per Matthew's instruction).
NHS signing key (the incident). Rotated: new RSA-4096 pair shared by both apps (kid unchanged). The private key now lives at config/secrets/nhs/nhs_private_key.pem (untracked, mode 600) with a legacy root fallback; nhs_auth.py / validate_eps_config.py resolve it there. Deployed to both VPS2 stacks and verified (JWT signs; dental app healthy). Untracked the four key files in both repos and gitignored *.pem/*.key; .dockerignore now excludes config/secrets/ and key material. The old key is destroyed from every host copy (VPS1 dev clones, backup dir, VPS2 pre-git copy); it remains only in git history and old image layers (now revoked) until the deferred purge/prune. Dental 03676be4; GP 3e16f28..7b1661f, both pushed.
Sweep (gitleaks). Removed hardcoded credentials from tracked source: the dental OIDC client secret in users/auth.py (now env-driven via DENTAL_OIDC_CLIENT_ID/SECRET), the AES client-secret fallback, the Trilium ETAPI token, the NHS sandbox API key in scripts, and the scratch files temp_settings.py/test_key.py. Two live secrets still need rotation (not code): the Keycloak dental-booking-app client secret, and the central DB password exposed in the historical .env.bak.
ORTHANC webhook token. Rotated both sides (Tenant dental + config/secrets/orthanc.env), recreated dental_orthanc, verified end-to-end (test instance -> webhook accepted -> Celery ingest_orthanc_instance ran).
GP Redis healthcheck. Ported the dental REDISCLI_AUTH + grep -q PONG pattern (no password on the command line).
GP stranded work. Reviewed and committed in four logical commits, pushed to main (7b1661f): platform-VPS health + compliance security check-in (incl. the four untracked migrations), integrations-dashboard retirement, hardcoded DB/Redis password removal, and a reconstructed 0013_add_lynis_hardening_index migration. Working tree clean; manage.py check passes.
Incidental fix. A stale directory at /tmp/scan-analysis.json broke the GP container's ro bind mount on restart; regenerated the file and hardened analyze_scans.py + /opt/scripts/sync-status-json.sh (ops repo 97a81d4, pushed).
Still open / for Matthew: (1) git-history purge + force-push both repos (deferred pending his review) + Forgejo GC; (2) rotate the Keycloak dental client secret and the central DB password; (3) the migration history has further orphaned recorder entries (integrations 0010_practice_mailbox_model, 0014_agentknowledgebase_category_and_more) so a fresh test DB still cannot build — a dedicated migration-history cleanup is needed.
Status: OPEN
OpenCode handled every 2026-09-10 from-hermes note (moved to archive/2026-09/from-hermes). Commit 99dbff27 on feature/dental-nhs-accounting, NOT pushed — held for Matthew. Deployed to test-client-dental; migration 0031 applied to central veripath_dental + tenant; dental_app + dental_celery_worker restarted healthy.
Changes (file pointers for the doc pass):
Tests: new guard/display tests in dental/tests_course_billing.py; Stripe usability, error-leak and seed-reset tests in dental/tests_payments_r1.py. Full dental suite 281 tests — only the 3 pre-existing tests_charting_r2 DatabaseOperationForbidden failures.
For you (Hermes): the UDA-round demo re-run + doc pass still stands; the new course-card/readiness/exemption copy and the "billed appointment" state are worth a docs line. Evidence rows (#234/#235/#20, tx #576, invoice #20) left in place for Matthew to decide on cleanup.
Status: OPEN
OpenCode completed the FP17 UDA completion round on feature/dental-nhs-accounting (baseline M1–M6 atomic commit ff00589e, then the U-round commit) per development/projects/dental_app/fp17-uda-completion. Not pushed — held for Matthew. Live: migrations 0030 applied central veripath_dental + test-client-dental (claim 232 re-derived 2A; exempt/Rx FP17TreatmentCode catalogue seeded); dental_app + dental_celery_worker restarted healthy; provision_erpnext_config --provision-nhs-accounts re-run on practice 58 (TESTCD01) with --realm test-client-dental — Superannuation Payable + Associate Fees now exist and are wired. Code summary: banding engine returns codes (1/2A/2B/2C/3/U/X_*; molar RCT 2C; EMERGENCY-only U); code-keyed Decimal UDA map (urgent via contract default 1.2, exempt 0.5/1.2 decimals survive pay + statement match); charge-exempt CoT closes £0 charge/fee basis with UDA credit and no patient exemption; Rx-only closes with no claim; continuation window (within 2 months auto-join; beyond -> new course, never auto-closes) + two-month NHSBSA submit rule at transmit; associate-pay ERPNext posting with 4th superannuation leg (F3b statement journal stays 3-leg); contract-performance monitor (YTD/96% at 30 Sep/shortfall + labelled clawback estimate). Tests: new dental/tests_fp17_completion.py (43) + course-billing/finance/payments/band-charge/provision/accounting suites green. NOTE for the doc pass + demo: the full test dental run reports 3 pre-existing failures in tests_charting_r2 (DatabaseOperationForbidden — users writes route to sector_gp not in that class's databases), unrelated to this round. For you (M7): re-run the three-story demo against the new engine (Mei closes 2A/£76.60/3 UDAs; Fatima exempt still £0 charge + 76.60 fee basis; close an urgent or 2C/3 story live to show the new codes), then the one-page-at-a-time doc pass (fp17-claim-submission, uda-auditing, invoicing, finance intro + sub-band/exempt/continuation content, co-payment-splitting retire).
Status: PARTIAL (P3 schedule/continuation items remain product-gated). Commit 9d8bded4 on feature/dental-perio-r1 pushed to Forgejo (working tree otherwise carries unrelated pre-existing changes, untouched). dental_app + celery worker healthy; full dental suite 203 green; live demo front door (test-client-dental.dental.veripath.co.uk -> VPS1 :8011) serves the new code.
Status: PARTIAL (P1+P2 shipped; P3 still OPEN). Working tree on feature/dental-perio-r1 remains uncommitted.
Completes Phase 2 of the redesign spec (development/projects/dental_app/fp17-course-billing-redesign). 47+60 dental tests green across course-billing / payments / band-charges / invoice-sync / financial suites; dental_app + worker restarted healthy.
Retired (no split payments, D4): removed fp17.split_co_payment, payment.reconcile_claim_payment, the claim Reconcile action (views + API + template button + URL) and the NHS two-invoice sync_fp17_claim_to_erpnext task. payment_outstanding = total - deposit (PARTIAL is deposit, not an NHS split); settle_invoices_for settles patient-liability invoices only.
Patient-charge + private ERPNext leg (D4 + private-income decision): billing.close_course now raises a local patient Invoice per charge transaction at completion (TRANSACTION for NHS statutory band charge, new PRIVATE type for private price-list). New sync_invoice_to_erpnext task creates + submits the patient Sales Invoice in ERPNext (idempotent; skips retired types). Course-close view enqueues it. sync_payment_to_erpnext Payment Entry amount basis = the selected invoice's amount (never tx.total) — the 417 fix.
Exemption capture (FP17PR): DentalPatientProfile.exemption_category + exemption_evidence_seen (migration 0027 applied central + tenant); course close resolves exemption from course then patient profile; exempt claims carry £0 patient charge + code; NHS fee basis still recorded (total_nhs_fee, date-anchored).
Claims-list Fee column (spec item 7): FP17Claim.fee_amount = sum of patient invoices else frozen patient charge (no more £0.00/£N/A); Course column added; caption updated.
ERPNext GBP precision site fix (417 root cause 2) — APPLIED LIVE: both sites' (dental + GP) and accounts.veripath's Currency GBP had smallest_currency_fraction_value = 0.0, which drives round_based_on_smallest_currency_fraction → every rounded total/outstanding went to whole pounds (18.4 -> 18.0). Set to 0.01 on all three sites; round test £18.40 preserved. Repo guard added: Frappe patch healthcare_accounting.patches.ensure_gbp_precision (patches.txt [post_model_sync]) + both bootstrap scripts set 0.01 on fresh provisioning.
Legacy cleanup (D6): new cleanup_legacy_two_invoice command cancelled ACC-SINV-2026-00026/27/28 on ERPNext (docstatus 2 verified) and retired local rows 14-16 (NHS_REIMBURSABLE/PATIENT_COPAY) to CANCELLED on the demo tenant. ERPNextClient.cancel_sales_invoice added; client now surfaces frappe server messages in all sync errors (417 diagnosis gap closed).
Still OPEN / for you (Hermes) + Matthew: Phase 3 (NHS contract/income side — DCS127/129 schedule concept, continuation rules, urgent/free-repair edges, UDA dashboard £ wiring) — spec schedules it later. Demo decisions flagged: (a) private backfill — fresh course-close demo vs catch-up script incl. tx 515 (already PAID locally, no ERPNext record); (b) claims 214-218 remain as legacy ACCEPTED claims (no invoices now) — coordinated --reset re-prep will recreate the demo under the new flow; (c) doc pass for private-ledger-billing / co-payment-splitting (now retired) / invoicing / fp17-claim-submission once P1+P2 are reviewed.
Status: PARTIAL — Phase 1 shipped 2026-09-08 on feature/dental-perio-r1 (uncommitted working tree; migrations applied central veripath_dental + test-client-dental; dental_app + celery worker restarted healthy; 47 new/updated tests green). Phase 2 (exemption, split/co-pay removal, patient-charge + private ERPNext legs, GBP precision site fix) and Phase 3 (NHS income side) still OPEN against the redesign spec.
Implements the redesign spec D1–D4 charge-point/billing-unit decisions (development/projects/dental_app/fp17-course-billing-redesign). NOT yet pushed — branch feature/dental-perio-r1 carries it locally; OpenCode will commit/push when the user says. Changes:
Schema (migrations 0025 + 0026). New DentalCourseOfTreatment (patient, practice, clinician, is_private, nhs_band, status OPEN/COMPLETED/CANCELLED, opened_at/by, completed_at/by, patient_exemption + exemption_evidence_seen, partial unique = one OPEN course per patient+practice). DentalAppointment gains course FK + treatment_completed_at/_by. FP17Claim gains nullable course FK; appointment re-documented as the anchor appointment. Migration 0026 retrofits every existing claim (incl. demo 210–218) into a synthetic COMPLETED course so the new model is consistent on live DBs without touching claim data (tenant backfill verified: 9 courses, 0 claims without a course). Admin registrations added.
Attendance no longer bills (D1). appointment_confirm_arrival marks ATTENDED only; check-in billing (bill_appointment) removed and the confirm-arrival billing tests rewritten as attendance-only.
Course workflow (D2/D3). dental/services/billing.py rewritten around the course: open_course_for_appointment, complete_appointment_treatment (auto-links matching open course), close_course (idempotent; requires every attended appointment completed; no BOOKED appts remaining). close_course raises the claim/charges at the new point — NHS: FP17 DRAFT at the date-anchored statutory band charge + PENDING patient-charge transaction (non-exempt) or £0 + recorded exemption (exempt); Private: PENDING price-list transaction per completed appointment.
Banding engine (D2, spec item). fp17.band_for_course derives band from the course's COMPLETED charted treatment (Band 1 exam/X-ray/scale-polish/fluoride/sealant; Band 2 fillings/RCT/extractions; Band 3 lab items incl. crowns; FP17TreatmentCode band honoured), falling back to the exam appointment's band when nothing is charted.
Course-scoped clinical assembly (WP5). build_fp17_payload + markers now assemble the FP17 clinical dataset across the course's appointments (legacy single-appointment claims unchanged).
Views/URLs. appointment_open_course, appointment_complete_treatment, course_close (all @dental_clinician_required); appointment-detail template shows course status + actions.
Demo (D6, partial). seed_dental_demo gained _seed_courses (idempotent): upserts statutory band charges 2025/26+2026/27 + price list; creates 3 marker stories — Mei OPEN NHS band-2 course (close it live → band-2 claim at current 2026/27 rates), Daniel closed private hygiene course (PENDING £60), Fatima closed exempt NHS band-2 course (claim 223 DRAFT, £0, UNDER_18). Also fixed the seed's radiograph get_or_create idempotency bug (duplicate placeholder rows aborted reseeds). Legacy claims 214–218 / £95–£18.40 construct / ACC-SINV-00026/27/28 remain until the Phase 2 removal + coordinated demo reset.
Tests. dental/tests_course_billing.py (37 tests: attendance-only confirm, open/link/complete/close lifecycle, NHS band-2/3 + exempt close, private close, close idempotency + readiness, banding engine, course-scoped payload, view RBAC); tests_financial_r3.py rewritten attendance-only. Green alongside financial/band-charge/payments/price-list/provision suites.
Status: HANDLED 2026-09-12 — verified by Hermes against the live system: rotated credential logs in successfully, demo data intact (no reset), each security header now appears once with HSTS present on both vhosts, nav entries added, and the pharmacy suite runs green (20 tests, run by Hermes). Corrections accepted: the demo users ARE group members (my earlier reading was wrong), and dropping nginx HSTS would have removed it — good catch.
All seven OPEN from-hermes notes handled (marked HANDLED 2026-09-12). Everything committed and deployed
on the live systems; nothing held back. Summary with file pointers.
Pharmacy (note 6 + 5 items)
pharmacy passwords reset fordemo.rp.one / demo.rp.two; the matching central Django users were updated targeted (no --reset,seed_pharmacy_demo now reads PHARMACY_DEMO_PASSWORD and/root/.config/pharmacy-demo-password.txt (0600).agents/handoff/from-opencode and agents/handoff/opencode-resume.8505c38) — a history purge is a separate task.add_header lines from the dental + pharmacy vhosts (hostveripath-ops, commit 0c9dbfd). Correction worth recording: Django did not emit HSTSSECURE_HSTS_SECONDS / SECURE_HSTS_INCLUDE_SUBDOMAINS / SECURE_HSTS_PRELOAD plus explicitdsp_clinic/settings.py. Verified single,scripts/run_pharmacy_tests.sh + README: shared venv,DJANGO_SETTINGS_MODULE=dsp_clinic.test_settings, credentials read from the host-local/root/.config/veripath-db.conf. No secret in the repo. 20 tests green.pharmacy/templates/pharmacy/base.html forPHARMACY_BUSINESS_MANAGER / PHARMACY_RESPONSIBLE_PHARMACIST.RESPONSIBLE_PHARMACIST group; the client simply hasgroups protocol mapper. The app reads the role user attributeusers/auth.py:_pharmacy_role_from_claims, PHARMACY_ROLE_MAP). Documented as the contract; no authphase1/onboarding-rp-log = dev = 3e334bc; pharmacy stack healthy;/oidc/authenticate/ still targets realm pharmacy / client pharmacy-booking-app.Infra (note 8, then 7)
2c VPS1 host PG16 listen_addresses='localhost', restarted — now bound to 127.0.0.1 only.3a VPS2 GP: dropped the client-postgres service and its depends_on; CLIENT_DB_HOST=172.18.0.1,CLIENT_DB_PORT=5435 (the tunnel). The duplicate container and its empty volume were retired. GP371f1e8.2a The VPS3 ERPNext tree is now a real git clone of matthew/erpnext-deploy (it was hand-copied andensure_gbp_precision.py and the patches.txt post_model_sync line — a rebuild would have3910bad). The VPS3-deployed resource limits3b5bf56).2b erpnext_veripath_postgres is now declared in compose (db service, externalerpnext_veripath_pg_data volume, 172.18.0.1:5438, attached to the hub network so its pg_hbaveripath_accounting intact; all three sites ping 200.3b Correction: gp_booking_redis was not stray — ERPNext's common_site_config.json used itredis service erpnext_redis, config repointed,Dental FP17 (note 2026-09-10 ×2)
close_course now populates claim.treatment_codes from the course's completed,dental/services/billing.py::treatment_codes_for_course), so the claim carries its ownsubmit_claim now sets ACCEPTED / PARTIAL on an accepted gateway response (wasTRANSMITTED), so CREDIT_STATUSES / UDA_CREDIT_STATUSES, the dashboard "Accepted" count and theTRANSMITTED is retained for a future asynchronous flow. Test updated.feature/dental-nhs-accounting = 8468fdb, deployed to VPS2; targeted suites greenInfra drift fixes worth recording
For Hermes
/root/.config/pharmacy-demo-password.txt (do not repeat it in the wiki).dev = 3e334bc; the test command is documented.Status: HANDLED 2026-09-12 — verified by Hermes against the code, the live site, Keycloak and the client database; the RP sign-on/off cycle was exercised live and confirmed, and Phase 1 is signed off. Follow-ups tracked in from-hermes notes (5) and (6).
Phase 1 is implemented on phase1/onboarding-rp-log (also merged to dev), deployed to the app-engine
stack and verified live. main remains the Phase 0 stable branch.
Branch pointers. phase1/onboarding-rp-log = dev = f33e41a (Phase 1 fix); base Phase 1 commit
8505c38. Repo matthew/pharmacy_booking_app.
File pointers for the doc pass
users/auth.py (_is_pharmacy_request, _pharmacy_role_from_claims,PHARMACY_CLIENT_ID/REALM, backend + logout realm branches); users/oidc_views.py; role enum inusers/models.py (PHARMACY_RESPONSIBLE_PHARMACIST, PHARMACY_DISPENSING_TECHNICIAN,PHARMACY_COUNTER_ASSISTANT, PHARMACY_BUSINESS_MANAGER); view-layer gates incore/permissions.py (PharmacyRoleRequiredMixin + three mixins) and core/middleware.py/pharmacy/); routing in dashboards/routing.py.pharmacy/models.py::PharmacyPremises; form pharmacy/forms.py::PremisesForm;pharmacy/views.py::PremisesProfileView; template pharmacy/templates/pharmacy/premises_form.html.pharmacy/models.py::PremisesVerification (who/when/method/evidence only);pharmacy/views.py::PremisesVerificationView writes the ODS/GPhC numbers to centraltenancy.Tenant.config.pharmacy/models.py::ResponsiblePharmacistLog (partial unique indexone_active_rp_per_premises, PROTECT user FK with db_constraint=False, append-only delete()RPSignOnView / RPSignOffView / RPHistoryView; dashboard widgetpharmacy/templates/pharmacy/dashboard.html.pharmacy/models.py::PharmacyAuditEvent + record_audit() (client-tier, append-only).pharmacy/management/commands/seed_pharmacy_demo.py.pharmacy/tests.py (19 green), run with DJANGO_SETTINGS_MODULE=dsp_clinic.test_settings.Live verification. https://pharmacy.veripath.co.uk/oidc/authenticate/ now targets
/realms/pharmacy/...&client_id=pharmacy-booking-app; /pharmacy/ redirects anonymous users to that
login. Demo Keycloak users demo.rp.one / demo.rp.two exist in realm pharmacy, are members of the
RESPONSIBLE_PHARMACIST group and carry the role=PHARMACY_RESPONSIBLE_PHARMACIST attribute; the
matching Django users and synthetic premises come from seed_pharmacy_demo (password
the host-local demo password (see /root/.config/pharmacy-demo-password.txt on VPS1)). Walkthrough: sign in as demo.rp.one, then /pharmacy/rp/sign-on/.
Where the spec met reality
users app is central for every sector (dentalusers migration history coherent and avoid a cross-database authGLOBAL_APPS); the client-tierResponsiblePharmacistLog carries the name snapshot and personal GPhC number, so the RP recordPartnerOrg. create_partner_realm's management command creates aPartnerOrg, but the partner.utils function does not. The apex tenant therefore has nopartner_org, so W1 needed an explicit pharmacy branch in the OIDC backend rather than the partner{slug}.pharmacy.veripath.co.uk) will also need its client id setpharmacy-booking-app — the partner branch now handles that.appointments 0023 and 0027 both add staff_role_required). I addeddsp_clinic/test_settings.py, which builds the test schema from the models. This is theusers.role needed widening to 40 chars for PHARMACY_RESPONSIBLE_PHARMACIST, plus the fourusers migration (0025) applied to the shared central DB. Because it is aClientVPS.Sector left untouched, per the spec.Phase 0 close-out. The ops working copy is committed and pushed (veripath-ops c113447:
tun unit 8020, bring-up, stack guard, sync-static, pharmacy vhost). TLS was already done by you — not
touched. Security-header finding: the pharmacy and dental vhosts each add HSTS/X-Frame-Options/
X-Content-Type-Options/Referrer-Policy while Django already emits its own, producing duplicated headers
and two conflicting Referrer-Policy values (same-origin from Django, strict-origin-when-cross-origin
from nginx). The GP vhost does not set them. Recommend dropping the four add_header lines from the
dental/pharmacy vhosts (Django owns them) — left as a small follow-up, not changed live.
Status: HANDLED 2026-09-12 — blocker cleared: the subdomain was created, Hermes issued and verified TLS, and Phase 0 was closed out (from-hermes notes (2) and (3)).
Base forked. gp_booking_app main 8901e407780766b5c4326b0c8e48ebabbe6ee7ce — the newest
common base (GP main is 24 commits past the dental/GP common ancestor 214585f and carries the
latest shared security/infra fixes). New private repo matthew/pharmacy_booking_app on Forgejo,
branches main + dev; working copy /root/work/pharmacy_booking_app.
Stripped. Removed the stale dental/ app (other-sector, superseded by dental_booking_app) and
the GP-only pcn_booking/; cleaned every dental reference out of patient_portal
(views/serializers/urls), tenancy/views, dashboards/routing, settings, urls and the
appointment commands; deleted scripts/onboard_dental_practice.py. The rest of the shared tree was
kept, exactly as the dental fork did — residual GP apps remain for later phases.
Added. pharmacy/ app skeleton (no clinical models) + a landing template. Router gains
PHARMACY_APPS = {'pharmacy'} and a data-driven SECTOR_APP_PARTITIONS registry (adding a module is
a one-line registration); sector_pharmacy DB alias with its own PHARMACY_DB_* env (falls back to
DB_*); pharmacy hosts in ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS.
Sector + tenant (central dsp_clinic, VPS1). Sector slug pharmacy (name Pharmacy);
Tenant #6 subdomain pharmacy, name VeriPath Pharmacy, db veripath_pharmacy, host
172.18.0.1:5435 (client Postgres), user fh_app_user, encrypted creds, config={sector, host_prefix}. Database veripath_pharmacy created on client_vps_postgres; migrate --database=sector_pharmacy applied django_migrations only — matching the dental sector-DB pattern
(no clinical tables yet). Command: pharmacy/management/commands/seed_pharmacy_sector.py.
Keycloak (realm pharmacy, via the existing create_partner_realm path). Clients
pharmacy-booking-app (confidential, secret issued) + erpnext (copied from the veripath realm).
The four agreed roles created verbatim on both clients and as realm roles:
RESPONSIBLE_PHARMACIST, DISPENSING_TECHNICIAN, COUNTER_ASSISTANT, BUSINESS_MANAGER. One group
per role, each mapped to both clients' matching role. create_partner_realm was parameterised
(client_id, blueprint_name, host) in a backwards-compatible way; new
partner/keycloak/pharmacy-realm-blueprint.json; command
pharmacy/management/commands/provision_pharmacy_realm.py.
VPS2 stack (app-engine). /root/work/pharmacy_booking_app cloned via a new read-only Forgejo
deploy key. docker-compose.yml: pharmacy_app (:8020) + celery worker + celery beat +
pharmacy_redis, mirroring the corrected dental pattern — host-specific DB host/port, no local
client Postgres service, Redis password kept off the healthcheck command line. All four containers
healthy; /pharmacy/ serves the skeleton page. Env lives on VPS2 only
(config/secrets/env.production, .env, mode 600).
Wiring. tun-apps-vps2.service now forwards 8020; ops repo updated
(nginx/enabled/pharmacy.veripath.co.uk.conf, bring-up.sh, sync-static.sh,
vps2-stack-guard.sh) and installed live; static synced; an HTTP-only nginx bootstrap is enabled.
BLOCKER — DNS. pharmacy.veripath.co.uk does not resolve yet (Matthew to create the subdomain →
VPS1 88.208.212.211). Certbot cannot run until then. Once it resolves: certbot certonly --webroot -w /var/www/letsencrypt -d pharmacy.veripath.co.uk, remove the HTTP bootstrap, enable the full
vhost, reload nginx.
Parked tickets. 3a — the duplicate client_vps_postgres on VPS2 is confirmed empty (0 rows
in every table) against the canonical VPS1 copy, so it is safe to retire; GP's compose still pins
CLIENT_DB_HOST=client_vps_postgres, so the fix is to point it at the tunnel (172.18.0.1:5435) and
drop the service — the pharmacy stack already avoids this. 2c (VPS1 host PG on 0.0.0.0:5432)
still to tighten. 2a, 2b, 3b and the dental FP17/user-guide notes parked.
Module-map corrections found in practice.
pharmacy.veripath.co.uk; a client tenant subdomain istest-client-pharmacy →test-client-pharmacy.pharmacy.veripath.co.uk (host prefix = tenant subdomain, same astest-client.pharmacy… shape would need a host/subdomain decoupling.infrastructure.ClientVPS.Sector has no pharmacy choice; adding one needs a migration on theveripath realm; wiring the pharmacy realm intoStatus: HANDLED 2026-09-09 (M7 steps 1–2 done by Hermes: ERPNext NHS accounts provisioned + config row wired incl. the keycloak_realm fix — provision defaults --realm to veripath, re-ran with --realm test-client-dental; seed re-run (no reset) backfilled contract 2026/27 + Sarah/Omar pay profiles. Testing PAUSED by Matthew until the FP17 UDA completion round lands — see the OPEN top note in from-hermes; the three-story demo, M4/M6 exercise and doc pass resume after it.). OpenCode built and tested M1–M6 on branch feature/dental-nhs-accounting (off 9d8bded4, live /app); the from-hermes 2026-09-08 note is marked HANDLED. Migrations 0028/0029 already applied to central veripath_dental + test-client-dental. dental_app healthy; billing/finance/payments/band-charge/provision/uda suites + new dental/tests_accounting green. ERPNext-side: F3b journal + account provisioning are frappe-standard REST (no erpnext-deploy change needed); verified ERPNext v15 accepts a Liability-root account as the Sales Invoice income_account (validate_account_head only checks company + non-group).
For M7, do (in order):
python manage.py provision_erpnext_config --tenant test-client-dental --provision-nhs-accounts [--income-account ... --bank-account ...] (idempotent; creates 'NHS Patient Charges (Clearing)' under Liabilities and 'NHS Contract Income' under Income; resolves parent groups automatically; note the spec's 'practice pk 58' is stale — current live practice is pk 57, resolve by --ods/--practice). Confirm row has nhs_patient_charge_account / nhs_contract_income_account / default_bank_account BEFORE any re-sync/reset.seed_dental_demo --tenant test-client-dental --reset now yields the clean new-model baseline (no course-less claims, no legacy local-only PAID tx, no REJECTED claim; NHS contract 2026/27 + Sarah/Omar pay profiles seeded).Notes: retired enum values NHS_REIMBURSABLE/PATIENT_COPAY are hidden from all UIs (Invoice.is_retired) but legacy rows are still matched/cleaned by raw value. The 2026-09-08 from-hermes seed-fix note is absorbed (see spec M7). Statement posting currently refuses superannuation > 0 (3-leg F3b journal; a 4th superannuation leg is a later phase).
Status: HANDLED 2026-09-07 (implemented in ab54f27b on feature/dental-perio-r1, pushed; migration 0024 applied to central veripath_dental + test-client-dental; dental_app + celery worker restarted healthy; 17 new tests green. Hermes: Matthew can live-test card/cash at the surgery on the demo tenant now — Take Payment queue + record-payment entry points.)
Mirror of the GP booking app staff payment facility (your reference: appointments RecordPaymentsListView / RecordPaymentView / CreatePaymentIntentView).
Schema (migration 0024). DentalTransaction gains payment-capture fields mirroring GP: payment_method (CASH / CARD / BANK_TRANSFER / CHEQUE / STRIPE, blank until settled), reference, paid_by, notes, paid_at, recorded_by (FK->staff user). Admin list updated.
Logic (dental/payment.py).
payment_outstanding(tx): PENDING = total − deposit; PARTIAL NHS = the reconciled patient co-payment (live 1292 = £18.40, not the £95 course total); fully-NHS-covered PARTIAL (parked semantics) = £0 → not queued.record_transaction_payment(...): idempotent settle → PAID + capture fields + stripe ids.settle_invoices_for(tx): NHS co-payment settles the linked PATIENT_COPAY invoice only (NHS_REIMBURSABLE stays SENT — paid via the separate NHS Payment Entry); otherwise the appointment's non-NHS invoices.get_stripe_config + invoice-aware _enqueue_erpnext_payment_sync.Views (roles: DENTAL_RECEPTIONIST / DENTAL_PRACTICE_MANAGER / DENTAL_ADMIN + ADMIN/SU).
GET /dental/payments/queue/ — appointments with money outstanding (amount + funding + claim link), total to collect.GET/POST /dental/appointments/<pk>/record-payment/ — amount (defaulted to the outstanding), method, reference/paid-by/notes; card requires a confirmed stripe_payment_intent_id; settles tx, flips invoices, enqueues the ERPNext sync.POST /dental/appointments/<pk>/create-payment-intent/ — check_only returns the publishable key; otherwise creates a PaymentIntent (automatic methods, no redirects) and confirms immediately when a payment_method_id is posted (server-side — does NOT depend on the webhook).UI. "Take Payment" nav link (visible to receptionists — the financial group excludes them), existing "Payments" nav relabelled Payment Settings, and Record-payment buttons on appointment detail + the month-appointments financial view.
ERPNext idempotency. sync_payment_to_erpnext (integrations/tasks.py) now guards already-synced transactions (SKIPPED 'Already synced') and takes an optional invoice_id so a co-payment Payment Entry is applied to the PATIENT_COPAY invoice rather than the first synced invoice on the appointment.
Tests. New dental/tests_payments_r1.py (17): role gates (receptionist/PM/admin allowed, clinician/patient 403), queue listing + correct outstanding amounts (incl. the reconciled £18.40), cash + card settlement with capture fields, co-payment invoice flip (PATIENT_COPAY→PAID, NHS stays SENT), double-submit idempotency, Stripe create/confirm (stubbed), ERPNext sync idempotency. Dental suite 160 pass / 18 pre-existing environmental errors (dicom/DRF/triage redis + MinIO on this dev host) — unchanged.
Deployment. ab54f27b on feature/dental-perio-r1, pushed to Forgejo; migration applied to sector_dental (veripath_dental) + test-client-dental; dental_app + dental_celery_worker restarted and healthy; new columns verified in the tenant DB; in-container URL check passes.
For the live test (Matthew). Test keys are already set on practice 57, so the card path works with a Stripe test card in the browser. Queued now: 1292 (£18.40 co-pay), 1294/1295/1297/1298 (private PENDING). Parked unchanged: deposit semantics/confirm_deposit redesign, webhook-secret verification against the Stripe dashboard, fully-NHS-covered-tx PARTIAL semantics.
Status: HANDLED 2026-09-07 (both 2026-09-07 OPEN notes implemented in b181a295 on feature/dental-perio-r1, pushed, dental_app + celery worker restarted, healthy; 17 new tests green, related finance suites 59 green. Hermes: pre-mark the past-dated Sept appts attended via script and retest; the invoicing/co-payment doc pass stays yours.)
A. Reception check-in billing — billable point = arrival (HIGH note).
appointment_confirm_arrival (/dental/appointments/<pk>/confirm-arrival/), open to all booking-staff roles (DENTAL_RECEPTIONIST / CLINICIAN / HYGIENIST / PRACTICE_MANAGER / ADMIN / ADMIN + SU); only today's/past BOOKED appointments can be confirmed; DNA/CANCELLED unchanged.dental/services/billing.py::bill_appointment:
create_claim_from_appointment (date-anchored band charge snapshotted at the appointment date; OneToOne keeps it idempotent). Bandless NHS -> attended, no claim, warning.B. FP17 claim <-> invoice linkage (LOW note).
total_nhs_fee band basis, NOT the invoiced amount (addresses the £74.80-vs-£70.70 scanning confusion).fp17_claim_detail.claim.invoices (existing FK) is prefetched.Tests. New dental/tests_financial_r3.py (17): private bill at the price-list amount (PENDING, deposit 0); NHS confirm creates a DRAFT claim with the frozen 2026/27 band-2 charge (£76.60 for a Sep-2026 appointment); double-confirm + helper-level idempotency (one tx / one claim); unpriced-type guard; bandless-NHS guard; future-BOOKED rejected; role gates (receptionist/clinician/hygienist allowed, patient 403, GET 405); claim<->invoice visibility across claims list / invoices list / claim detail. Related suites re-run green (financial R1/R2, band charges, price list, ERPNext provision). Full dental run: 143 pass / 18 errors — the 18 are the pre-existing environmental dicom/DRF/triage cases (redis:6379 / MinIO not reachable from this dev host), unrelated to this change.
Deployment. Commit b181a295 on feature/dental-perio-r1, pushed to Forgejo; dental_app + dental_celery_worker restarted on the live host and healthy; in-container URL check confirms the new route resolves. No migration (no schema change).
Scope guards honoured: revenue semantics unchanged (a PENDING private tx = money owed, not revenue until collected); DNA/CANCELLED handling unchanged; patient-portal Path B untouched; rejected-claim->patient-bill rule still parked; the fully-NHS-covered-tx-PARTIAL semantics question remains parked for Matthew.
Status: HANDLED 2026-09-07 (both remaining OPEN items done + deployed; erpnext_v15:latest rebuilt on VPS3, containers recreated healthy, assets re-synced. Hermes: code parts done — the site-config parts of the UI-trim mission are yours when Matthew starts.)
O1 — UI TRIM code parts (erpnext-deploy, commits 5045d50 → dceec4a). All on the shared healthcare_accounting app (image rebuilt + deployed; GP + dental sites both verified healthy).
login_via_keycloak (config-driven, per site):
site_config erpnext_role_map (JSON object) → per-sector map by hostname (DENTAL_ROLE_MAP / GP_ROLE_MAP) → None (legacy flat grant of Accounts/Sales/Stock User). GP-safe: no site_config override + no provisioned SSO users there yet, and GP's map only applies on next SSO login.DENTAL_PRACTICE_MANAGER + DENTAL_ADMIN → Accounts Manager; DENTAL_RECEPTIONIST → Accounts User; default (clinicians/hygienists/others) → Accounts User. Verified live on the dental site: MGR→Accounts Manager, RECEP→Accounts User, CLIN→Accounts User, no-role→Accounts User.block_modules list (the 24-module trim from hide_modules_dental.py); existing SSO users are reconciled on every login (idempotent — only saves when roles/blocked list changed).erpnext-deploy/scripts/backfill_sso_trim.py: sets blocked modules on pre-trim SSO users. Roles deliberately NOT backfilled by guess (the Keycloak claim is only known at SSO login, which reconciles automatically). Ran it live on the dental site.manager@test-client-dental.co.uk now has roles ['Accounts Manager'] (was Accounts/Sales/Stock User) + 24 blocked modules. Reconcile exercised end-to-end.role protocol mapper (oidc-usermodel-attribute-mapper, claim role ← user attribute role, all token claims on) to the erpnext client in realm test-client-dental, byte-identical to the GP realm's mapper. Verified present via kcadm.frappe.local.host_name is request-scoped (absent in console) → use frappe.local.site for the hostname fallback; frappe.log_info does not exist in this frappe (would have raised AttributeError on the first existing-user reconcile) → frappe.logger("ERPNext SSO").info(...).O2 — asset-sync durability (two new scripts on VPS1).
/opt/scripts/erpnext-assets-sync.sh: streams sites/assets out of the erpnext_web container on VPS3 with --dereference (symlinked app public dirs → real files), stages into a temp dir, aborts if no assets.json, then atomically swaps into /var/www/erpnext-assets/assets./opt/scripts/erpnext-deploy.sh: the erpnext deploy/rebuild wrapper — rsyncs only the Docker build inputs (healthcare_accounting, patches, Dockerfile, entrypoint, config — never .env/compose/creds) VPS1→VPS3, docker build, compose up -d --force-recreate, waits for healthy, optional bench build, then calls the asset-sync. Run --no-bench-build for pure-python changes.dceec4a), containers healthy, and both sites serve the built bundles over VPS1 nginx — desk.bundle.WHKH7GK2.js 200 (1.37MB) + frappe-web.bundle.VXI26CZQ.js 200 (0.83MB) on dental AND gp. Ping 200 both sites.Notes / park items: UI-trim workspace-visibility + settings hides are yours (site-config pass, Hermes). GP realm role-map hostname fallback is active but untested (no GP SSO users yet — will apply on first GP SSO login; adjust GP_ROLE_MAP when Matthew confirms GP role values). scripts/erpnext_api.py + training-notes working-tree edits were pre-existing Hermes changes — left untouched, not committed.
Status: HANDLED 2026-09-07 (a6d93e8e on feature/dental-perio-r1, pushed, dental_app + celery worker restarted; full dental suite 144 green incl. 9 new tests. Hermes: nothing further — parked items below are decisions for Matthew.)
Both parts of the 2026-09-06 hardening ask are done and deployed.
A. provision_erpnext_config hardened (3 ingestion bugs).
clean_api_secret() (shared with the admin form): strips surrounding whitespace AND a stray leading '=' (the 401 root cause — KEY=value line parsed wrongly), validates alphanumeric 20+ chars, raises CommandError otherwise. A half-supplied pair (only one of key/secret) now errors instead of saving.practice_name hint (the 'Test Client Dental Practice' vs 'Test Client Dental' 417 root cause) is auto-corrected to the site's company. Multiple companies → error asking for --company.--verify flag: fails hard on auth/company mismatch; without it resolution is best-effort (warns and keeps the hint) so offline/later-admin provisioning still works. Reports "auth as " on success.DentalERPNextConfigAdmin uses a ModelForm applying the same credential cleaning/validation on save.B. sync_fp17_claim_to_erpnext idempotency guard (integration/tasks.py).
Invoice rows (NHS_REIMBURSABLE and/or PATIENT_COPAY) on the claim; if any exist it returns SKIPPED / 'Invoices already exist (N)' — a retry after partial success can never duplicate ERPNext Sales Invoices or local rows. Tested end-to-end: first run SUCCESS, second run SKIPPED, ERPNext client called exactly once, one local Invoice row.ERPNextClient additions (used by A): get_logged_user() (auth check via frappe.auth.get_logged_user), list_companies(), get_company(name).
Tests: new dental/tests_erpnext_provision.py (9): clean_api_secret strips '='/whitespace, rejects non-alnum/short, empty passes; guard skips already-synced (NHS-only and co-pay-only); fresh claim proceeds (no skip, ERPNext client reached); end-to-end no-duplicate on re-run. No network calls — ERPNextClient is stubbed/mocked.
Parked (for Matthew, per your note): (a) fully-NHS-covered tx still marked PARTIAL (patient_share < total → true when patient owes £0) — semantics question for the collected/still-due UI, no code change made; (b) nothing else. Scope guards honoured: no schema change, live practice-57 row untouched, patient-portal Path B untouched, ERPNext asset-sync (O2) + UI-trim code (O1) not in this pass.
Status: HANDLED 2026-09-06 (both builds implemented in 353ceee1 on feature/dental-perio-r1, pushed; migration applied central + test-client-dental; seeds run live; dental_app + celery restarted; dental suite 135 green. Hermes: review defaults in UI, run Phase-4 reconcile demo, document.)
Both 2026-09-06 builds are done and deployed.
A. Practice price list v1 (per appointment type).
DentalPriceListItem (practice FK, appointment_type from the 9 APPT_TYPE_CHOICES, price Decimal(8,2) — 0 = "not priced", is_active, updated_at; unique practice+appointment_type). Admin-registered. No VAT field (clinical dental VAT-exempt), no effective-dating in v1.seed_price_list (defaults central, --tenant for a client): CHECKUP 65, EMERGENCY 80, SCALE_POLISH 60, FILLING 95, EXTRACTION 120, ROOT_CANAL 350, HYGIENIST 60, CONSULTATION 85; ORTHODONTIC seeded "not priced". Never overwrites an existing (edited) price.B. Effective-dated NHS band charges (England).
NHSBandCharge (region, band 1/2/3/URGENT, amount, effective_from, effective_to null=open; unique region+band+effective_from). Admin-registered.get_band_charge(nhs_band, on_date=None) resolves the rate in force on the given date from the table (falls back to the old hardcoded dict when a DB has no rows — deprecation safety). create_claim_from_appointment now snapshots patient_charge at the appointment (course-start) date; the payload-build and upgrade-split fallbacks use the claim's appointment date; calculate_total_nhs_fee made date-aware (placeholder, no redesign); booking-assist cost estimates now read current rates via get_band_charge.seed_band_charges: England 2025/26 (26.80/73.50/319.10/26.80) + 2026/27 (27.90/76.60/332.10/27.90). Live now: 8 rows central + 8 rows test-client-dental (verified in tenant DB).Reconcile demo hygiene (for Hermes): existing seeded claims 210–213 keep their frozen charges (realistic — leave). Phase-4 demo needs a FRESH band-2 claim created after 2026-04-01 → patient_charge 76.60 → for total £95 the patient share is £95 − £76.60 = £18.40. Claim 212 (£70.70, below band-2) stays the documented edge case.
Scope guards honoured: England only, no practice-editable fee UI for band charges, no item-level/insurance/shortfall logic, no CoT grouping, rejected-claim→bill rule still parked, patient-portal Path B untouched.
Status: HANDLED 2026-09-06 (implemented in c361d53e on feature/dental-perio-r1, pushed, dental_app restarted; dental suite 122 green. Hermes: retest each item on test-client-dental practice 57 + update plan/doc decisions after.)
All five items from your 2026-09-06 note are implemented and deployed (commit c361d53e — only the financial files + tests changed; unrelated uncommitted working-tree edits left untouched).
1. Single-practice framing — fold-in (user decision). financial_dashboard resolves the caller's practice (linked provider practice, else the tenant's single active practice) and redirects to /dental/financial// — practice_financials IS the financial home. Per-practice table/totals removed. A multi-practice tenant with no provider link (not today's shape) gets a slim practice chooser. The financial suite is now role-gated to DENTAL_ADMIN / DENTAL_PRACTICE_MANAGER / ADMIN + superuser — a clinician/hygienist/receptionist direct URL is now 403 (was @login_required only); worth a role-matrix note. The Financial nav link already included DENTAL_PRACTICE_MANAGER (no change needed there).
2. Count semantics. NHS/Private/DNA are now the month's non-CANCELLED appointments in mutually exclusive buckets (a DNA is never double-counted); CANCELLED stays visible only in the appointments list. DNA-rate denominator changed to the non-CANCELLED activity base. When revenue is £0 but activity > 0 an explanatory note shows ("No payments recorded in yet. Revenue appears when deposits, balances or FP17 claim reconciliations are recorded."); the Revenue card caption reads "payments recorded in " so £0-with-14-bookings no longer reads as a contradiction.
3. Payments nav bug — option (a) built. New in-app practice-scoped screen /dental/settings/payments// (deposit %, currency, is_active, publishable key; Stripe secret + webhook secret masked set-only; webhook route shown). Payments nav points at it; the partner:stripe_config href and its active-state names are gone (last partner reference in the active template tree removed). Same manager roles as the rest of the group.
4. Month-scoped appointments link. New /dental/financial//appointments/ lists this month's appointments (CANCELLED + DNA included) with per-row funding badge (Private / NHS band / NHS FP17 Claim # link) + payment picture (billed, deposit collected, status badges, "no payments recorded"). Quick action on the financial home now points here instead of the all-time /dental/appointments/ list.
5. FP17 claims card is now month-scoped (claims on this month's appointments) and grouped so every live status is visible: Draft / In submission (queued + transmitted) / Accepted / Needs attention (rejected + partially paid).
Scope guards honoured: rejected-claim-to-patient-bill rule parked, no seats billing, patient-portal Path B untouched, DentalERPNextConfig for practice 57 untouched.
Retest hooks on test-client-dental (practice 57): /dental/financial/ lands on /dental/financial/57/; September activity excludes the CANCELLED from NHS/Private; revenue £0 with the new note; Payments opens the in-app config; FP17 card shows all live statuses; View Appointments gives per-row funding/payment detail.
Status: HANDLED 2026-09-05 (financial fix c25db51a deployed; ERPNext config re-provisioned for practice 57 — claim-reconciliation + two-invoice ERPNext retest unblocked)
Both 2026-09-05 financial items are done. Committed c25db51a on
feature/dental-perio-r1 (only dental/views.py + practice financials template +
new test file; unrelated uncommitted work in the tree left untouched), pushed,
dental_app restarted healthy. Full dental suite 106 tests green.
Item A — dashboard revenue month-scoped. financial_dashboard now sums
transactions on appointments that started in the current month (was all-time),
so the Total Revenue card + per-practice Revenue column match practice_financials
and the page subtitle. Live consequence for September: the dashboard card now
reads £0 alongside /dental/financial/57/'s £0 — the all-time £471.30 figure no
longer shows anywhere (that mix is now reachable via the monthly ledger below
once September revenue exists). Expected, both screens agree.
Item B — revenue figure now accountable. Practice Financials
(/dental/financial//) gains a "Revenue Breakdown — {{month}}" ledger
decomposing the month's revenue into its constituent DentalTransactions: date,
patient, appointment type, funding stream badge (NHS FP17 + band + link to the
claim / Private / NHS band only / Unclassified), amount, deposit, payment status
(PAID/PARTIAL/PENDING/REFUNDED). Summary chips: Private vs NHS-FP17 subtotals,
Collected vs Still due (PENDING/PARTIAL rows = total_amount − deposit_paid).
Funding stream is DERIVED per row from appointment.is_private / fp17_claim /
nhs_band — DentalTransaction has no funding field (no model change). Empty state
("No revenue recorded in {{month}}") when nothing billed. Placement decision:
ledger lives on the per-practice drill-down page (existing Details flow), not
inline on the dashboard. Tests: dental/tests_financial_r1.py (4).
ERPNext config re-provisioned (practice 57). dental_dentalerpnextconfig was
empty in test-client-dental (SSO/sync inert). Re-provisioned via
provision_erpnext_config --tenant test-client-dental --practice 57 against
https://test-client.accounts.dental.veripath.co.uk. For the record: the API
key/secret live on the ERPNext site itself (Administrator user) and are NOT
retrievable through the ERPNext web UI after creation — they were fetched
server-side on VPS3 via bench --site ... console
(/root/work/erpnext-deploy/scripts/setup_dental_api_key.py is the equivalent
standalone helper, but needs a bench console/cwd context to run). Key/secret were
piped straight into the provision command via stdin (never argv/history/logs);
temp files shredded. Verified row: practice 57 Test Client Dental Practice,
is_active=True, api_key/api_secret set, realm veripath. Keycloak SSO wiring
(Phase A) untouched.
Hermes: claim reconciliation + the two-invoice ERPNext flow can now run live.
Status: HANDLED 2026-09-09 (Hermes review — sub-items all landed 2026-09-04: FP17 markers d2f1f86b + chart edit ff9e500f, PWA split, veripath Nuxt cleanup f9e96d2; archived 2026-09-09)
All previously parked radar items are now implemented/answered. Branch
feature/dental-perio-r1 (dental app) + main (veripath-site). dental_app
restarted healthy after the dental commits.
1. FP17 clinical_markers now derived (commit d2f1f86b). build_fp17_payload no
longer hardcodes the markers. Aggregated from the appointment's chart entries:
radiographs_taken (linked radiographs exist), extractions_performed (COMPLETED
charts flagged is_extracted), fillings_restorations (restored surfaces across
COMPLETED charts; any surface token that is not crown/sealant/fluoride/
endodontic counts as a restoration — plain-text classification mirroring the
chart UI's free-text surface values), fluoride_varnish_applied (COMPLETED
fluoride-varnish surface). Only status=COMPLETED counts — PROPOSED is planned
and EXISTING is pre-existing, neither is treatment performed at this visit. The
SNOMED-controlled-list crosswalk you scoped stays a future trigger (not
needed for these four markers). Tests: FP17ClinicalMarkersTests (5), in
dental/tests.py.
2. Audited chart EDIT surface (commit ff9e500f). New /dental/charts//edit/
(chart_edit view + chart_edit.html), clinician-grade via @dental_clinician_required,
edits tooth/status/surfaces/procedure_code/is_extracted/notes on the existing
appointment+patient anchor. Every save writes an auditing.AuditLog UPDATE row
(field from/to diffs, actor, IP). Edit button on chart_detail is shown only to
clinician roles (can_edit context) so receptionists don't see a dead link.
This resolves the "no chart-edit UI" addendum — please update the
graphic-charting-logs manual, which currently states status is set at creation
and can't change. Tests: ChartEditTests (4).
3. PWA GP/dental split — clarified (decision recorded for marketing). The
portal is ONE shared PWA instance (patient.veripath.co.uk + per-client
subdomains): a single Vue codebase whose router carries /dental/* views
(DentalBook, DentalHousehold, DentalAppointmentDetail) alongside the GP
appointments/payments/dashboard views, with no per-sector brand config in the
app. Recommended near-term story for the marketing page: shared portal, sector-
scoped — "the patient app your patients already use now handles dental too —
book, triage and emergency routing for your practice on your own subdomain."
A separate dental-branded portal instance is a roadmap decision, not the
current architecture; if Matthew later wants one, the marketing page should be
revised then. Draft the dental PWA page against the shared-portal story.
4. veripath-site Nuxt toolchain removed (commit f9e96d2, pushed to main).
Deleted the Nuxt shell (app.vue, nuxt.config, tailwind.config, tsconfig,
layouts/default.vue, AuthBadge.vue, useAuth.ts, the whole pages/.vue tree,
server/api/.ts), scripts/patch-studio-auth.mjs, unreferenced
assets/css/main.css, and the stale Dockerfile/.dockerignore (no veripath-site
container runs; deploy is the host nginx script). package.json pruned to
dependencies {marked ^18, yaml ^2.9}; scripts: build = node build.mjs plus
start:proxy/auth/webhook. node_modules pruned 1551 pkgs; package-lock
regenerated (0 nuxt/vue refs). CMS untouched (public/admin CDN decap + all
three .mjs servers). Verified: node build.mjs completes (159 dist files).
IMPORTANT for Hermes: I did NOT run a live deploy for the veripath cleanup.
Your working tree still carries uncommitted user-manual edits
(emergency-slot-management.md rewrite + symptom-checker.png /
triage-hard-stop.png), and /opt/deploy-veripath-site.sh builds the working tree
— deploying now would publish in-progress docs. Once you commit those manuals,
run /opt/deploy-veripath-site.sh (or tell me and I will) to land the cleanup.
Verified totals: dental.tests + perio + charting r2/r3 = 80 pass before commit
(plus the 5 FP17 + 4 chart-edit in the run). radiographs thread already fully
HANDLED.
Follow-ups still on HERMES side (not OpenCode): perio doc pass (after
radiographs, per user rule), role-permissions-matrix + walkthrough-test-users
RBAC sections, updating graphic-charting-logs for the new edit surface, the
dental PWA marketing page, and committing the two veripath-site user manuals +
running the deploy.
Status: HANDLED 2026-09-05 (verified live on test-client-dental: hygienist nav shows Radiographs
link, present + visible; receptionist gets 403 on /dental/api/radiographs/ and the HTML page;
403 message reads "A dental clinician role is required for this clinical record." — thread closed)
Both area-testing observations addressed. Decisions taken:
Tests: RadiographRBACTests added (receptionist 403; clinician + hygienist 200 on
the radiograph API). dental.tests + dental.tests_perio_r1 = 59 pass.
Status: HANDLED 2026-09-04 (user confirmed live; radiographs area complete)
Matthew confirmed live that rx 172 now displays (no re-upload needed) — the
in-place reprocess + appointment-patient attribution (01212810) closed the loop.
Radiographs area is done: upload form (GET + POST), DICOM detection/ingest,
PNG + real DICOM rendering all verified. Nothing outstanding on this thread.
Status: HANDLED 2026-09-09 (attribution fix verified; radiographs CONFIRMED live by Matthew 2026-09-04 — DICOM visible, upload workflow done; archived 2026-09-09)
Follow-up to the DICOM-upload fix (1d00a145), discovered while confirming the
uploaded file was viewable:
Key finding: the real Instrumentarium panoramic uploaded to rx 172 carries
DICOM PatientID = '0' — NOT a real NHS number. Under strict NHS decrypt-and-
compare even the FIXED form would have rejected every upload of this file with
"did not match a registered patient", so "should DICOM images show now?" would
have been NO for Matthew's actual file.
Resolution (commit 01212810, pushed; dental_app restarted): form-based DICOM
uploads now attribute the patient from the APPOINTMENT the clinician picked, not
the DICOM header. dicom_ingest.store_radiograph/ingest_dicom_bytes gained a
patient=... override (skips the PatientID match when supplied); radiograph_upload
passes patient=appointment.patient. The unattended Orthanc/API ingestion path is
unchanged — it still requires a strict PatientID->NHS match and logs/skips on no
match (that path has no human anchor, so the header is authoritative there). This
supersedes the "flag if you'd rather it fall back" design note from 1d00a145's
report — resolved in favour of appointment-patient for the form.
Existing demo record repaired: rx 172 (Maria Lopez) reprocessed IN PLACE from
its stored bytes — raw archive now at raw/7/.dcm (8.2MB) and a rendered PNG
at processed/7/.png (1.35MB, verified PNG magic); record updated to
PANORAMIC (modality PX), captured_at from StudyDate, AI analyse + embed re-run.
It should render on the detail page now, alongside the real PNG rx 173.
Tests: form DICOM routing asserts the patient kwarg; new ingestion test proves a
PatientID '0' file lands on the override patient with type PX->PANORAMIC.
dental.tests + dental.tests_perio_r1 = 56 pass.
Hermes: safe to retest the DICOM upload with the Instrumentarium file — it will
now attach to the chosen appointment's patient and display. No action needed on
the seeded placeholder context note.
Status: HANDLED 2026-09-09 (retest covered by Matthew's live confirmation 2026-09-04; archived 2026-09-09)
Fixed commit 1d00a145 on feature/dental-perio-r1 (pushed; dental_app restarted).
You reproduced live exactly the quirk pre-flagged in 07236c61 (rx 172, Maria
Lopez: 8.2MB raw DICOM under processed/7/.png). The upload POST now sniffs
the payload instead of trusting the filename/content type:
Tests: added DICOM-routing (asserts ingest called with the raw bytes +
practice/appointment/source), unmatched-patient message, parse-error message,
and JPEG sniff (key ends .jpg + image/jpeg). RadiographUploadViewTests 7 pass;
dental.tests_perio_r1 now 31 pass.
One design note for the retest: patient attribution for DICOM uploads follows
the DICOM PatientID (NHS decrypt-and-compare), NOT the selected appointment's
patient — if a file is anchored to the wrong appointment's patient it will 500-
free fail with the "did not match a registered patient" message rather than
silently attaching to the appointment's patient. This matches the Orthanc
ingestion path and keeps NHS identity authoritative. Flag if you'd rather it
fall back to the appointment's patient on mismatch.
Hermes's context note about the 2026-09-02 seeded placeholders (rx 169/170 dark-
grey 320x200) — no action taken, acknowledged.
Charting FP17 + PWA + veripath-site items remain on the radar.
Status: HANDLED 2026-09-09 (retest covered by Matthew's live confirmation 2026-09-04; the optional 2D-DICOM seed_dicom_samples demo remains a non-blocking extra; archived 2026-09-09)
1. GET /dental/radiographs/upload/ 500 — FIXED (commit 07236c61 on feature/dental-perio-r1, pushed + app restarted)
Root cause exactly as you found: radiograph_upload()'s GET branch sliced the appointment
queryset [:100] BEFORE the practice filter, so any user linked to a practice hit Django's
"Cannot filter a query once a slice has been taken" TypeError. Fixed by applying the slice
after the filter (dental/views.py:2167). One-file change, no migration.
Scope sweep confirmed: no other slice-then-filter remains in dental/views.py (chart_create,
perio_exam_create, radiograph_list, the dashboard/note queries all filter before any slice).
Also added a regression test in RadiographUploadViewTests (test_get_with_linked_practice_
renders_form) that links a provider and asserts the GET renders 200 + the appointment option
— it would have 500'd before. Suite: dental.tests_perio_r1 now 27 pass.
Matthew's JPEG samples can now exercise the upload form. Note: the upload POST currently
stores the file as a PNG via MinIO regardless of type (minio_client.upload_bytes(...,'image/png')
with the JPEG bytes) — the form's accept="image/*" and current test fixture send PNG, but if
a real JPEG is POSTed it is saved under a .png key as raw JPEG bytes and served back as
image/png — worth a quick check during the POST retest. No action taken (out of the reported
scope); flag if you see it bite.
2. DICOM capability review — G1 confirmed, G2 confirmed (assessment; no code change)
Reviewed dicom_ingest.py / tasks.ingest_orthanc_instance / webhooks.orthanc_webhook /
orthanc/onstored.lua in the current tree.
G1 (CBCT/multi-frame) — CONFIRMED genuine but narrower than "3D volumes never work":
G2 (orientation) — CONFIRMED: dicom_to_png honours MONOCHROME1 inversion and windowing but
never reads ImageOrientationPatient (0020,0037); no flip path exists. For the current 2D
dental projections this is low-risk (vendor files here are anatomically upright), and any
fix must be SIGN-SENSITIVE, not a blanket flip (row/col direction-cosine signs decide
top-bottom / left-right), otherwise it would invert currently-correct images. Advise
deferring until a mirrored vendor sample exists to test against.
Both are genuine future-hardening items as you framed. Nothing blocking: the 2D DICOM path
can be tested live with seed_dicom_samples now that the upload fix is in.
Charting FP17 + PWA + veripath-site items remain on the radar.
Status: HANDLED 2026-09-09 (user retest all-green 2026-09-04 — see from-hermes 'PERIO R3 retested live' note; archived 2026-09-09)
Commit e2aa40a2 on feature/dental-perio-r1 (pushed; app restarted).
Migration 0022 applied to demo + prod. Tests: dental/tests_perio_r1.py now
26 pass; charting R2/R3 + dental suites (37) green.
Live-verified on test-client-dental: receptionist 403 across every surface;
clinician full flow create -> chart tooth (Plaque 100% live) -> complete
(completed_by stamped); demo left tidy (smoke exam cleaned up).
Hermes: retest on test-client-dental, then run the perio doc pass (guides one
page at a time, faithful screenshots, repo user-manual copies, "when is this
done" BPE/6PPC context). BPE Tiers 1 & 2 remain deferred.
Status: HANDLED 2026-09-09 (verified within the all-green R3 retest round 2026-09-04; periodontal-charting + plaque-and-bleeding-indices guides live; archived 2026-09-09)
Commit d8c7538f on feature/dental-perio-r1 (pushed; app restarted).
Tests: dental/tests_perio_r1.py now 15 pass (+ charting R2/R3/dental suites
37 green). Demo backfilled; live-verified.
Still flagged (no action requested): nothing sets completed_at via UI/API
(Open-badge unreachable); per-tooth entry is still API-only — a grid UI and a
complete action would close the loop (deferred user decisions).
Hermes: retest the perio pages on test-client-dental, then run the perio doc
pass (guides one page at a time, faithful screenshots).
Status: HANDLED 2026-09-09 (picker verified in the all-green R3 retest 2026-09-04; seed coherence shipped with R1–R3; archived 2026-09-09)
Commit 68db5ec7 on feature/dental-perio-r1 (pushed; app restarted).
Tests dental/tests_perio_r1.py still 10 pass.
perio_exam_create picker no longer filters by appointment type — it nowStill flagged (no action requested, on the radar): nothing sets completed_at
via UI/API, so exams created in-app stay Open/"No" forever (skeleton gap —
a complete action would close it). BPE screen + escalation remains a
deferred user decision.
Hermes: retest on test-client-dental, then run the perio doc pass (guides one
page at a time, faithful screenshots).
Status: HANDLED 2026-09-09 (user retest done; perio doc queue delivered — periodontal-charting, plaque-and-bleeding-indices, gingival-recession-mobility-scoring, fdi-coordinate-charting guides; archived 2026-09-09)
Branch feature/dental-perio-r1 (commit 0d14ab89) pushed. Migration 0021
applied to demo (test_client_dental) + prod (veripath_dental). Tests
dental/tests_perio_r1.py (10 pass) + charting R2/R3 suites (12) green.
What changed (mirrors charting R2/R3):
/dental/perio/create/patient/<pk>/:?appointment= preselect.Live-verified on test-client-dental: valid appointment -> 302 to the new
exam (no 404); patient-pk-as-appointment -> form error redraw, nothing
created; radiograph upload resolves patient/practice from the appointment.
DEFERRED by user decision (not done here, still on the radar):
Hermes doc queue (one page at a time per user rule): rewrite
periodontal-charting / plaque-and-bleeding-indices /
gingival-recession-mobility-scoring against the now-fixed UI, regenerate
stale perio screenshots, repo user-manual copies, "when is this done"
clinical section. Plus the STILL-QUEUED role-permissions-matrix +
walkthrough-test-users RBAC sections from charting R3.
Status: HANDLED 2026-09-09 (doc pass delivered — clinical-notes guide in guides/dental_app_guides/ + role-permissions-matrix clinical-notes RBAC coverage; walkthrough-test-users RBAC section logged as small residual in the housekeeping log; archived 2026-09-09)
Branch feature/dental-charting-r3 (commit 43f1008f) pushed; deployed to the
test-client-dental demo (VPS1) and VPS2 prod (file-sync + restart, both
healthy). No migration.
Changes:
Live-verified on demo + prod: dentist sees narrative, manager sees
"Content restricted to clinical staff" only, both 200, Open links work.
Ready for Hermes: update role-permissions-matrix + walkthrough-test-users
RBAC sections, then write the charting/clinical-notes user manual page.
Status: HANDLED 2026-09-02
Branch feature/dental-charting-r2 (commit 12112715): appointment now
required on chart create (form + view + model clean + DRF validate +
migration 0020 NOT NULL, zero-NULL audited first). Full report below this
was superseded by R3; see commit for detail.
Status: HANDLED 2026-09-02
R1 (chart<->appointment cross-linking + UI entry points) is on
feature/dental-charting (commit d9ceceba), live on demo + prod — see the
2026-09-02 report that preceded this one. Kept here as the audit pointer for
the R1->R2/R3 sequence. Superseded by R2/R3.
Status: HANDLED 2026-09-01 (acknowledged by Hermes, verified: commit c0dffe78 on feature/dental-triage; triage_level+triage_score now written by rule-based triage (ai_triage.py:238-239); dashboard filters start_time>=now with Past due + Routine buckets (views.py:1031-1051); 22/22 tests_triage pass in dental_app container)
Two user-reported bugs fixed, committed on feature/dental-triage
(commit c0dffe78, pushed), deployed + verified on VPS1 demo and VPS2
production. dental/tests_triage.py now 22 tests; full dental+users+portal
suite green.
BUG 1 — past-dated appointments sat in the active queues. Fix:
triage_dashboard active queues (Needs Triage / Triaged) now filter
start_time >= now; past-dated OPEN rows move to a dedicated "Past due"
section (still visible, same Review/Re-run/Handled actions).
BUG 2 — "Run triage" reported success but rows stayed in Needs Triage.
Root cause: triage_symptoms_rule_based never wrote triage_level. Fix:
it now writes triage_level: score_to_tier(score) + triage_score into
ai_analysis_json, so re-run and free-text booking triage both populate the
tier and rows transition into Triaged.
Also added a "Routine — no action needed" bucket: a re-run scoring 1
(ROUTINE) no longer makes the row vanish from both queues.
Design decisions (from the bug note's open questions): free-text bookings
now auto-tier at booking so Needs Triage drains to near-empty (matches the
plan — auto-tier -> review in Triaged); ROUTINE rows kept visible in their
own bucket; past-due handled via separate section (not auto-marked).
Live-verified on both environments: legacy row -> Run triage -> triage_level
written, leaves Needs Triage; past-dated OPEN row only in Past due section.
Existing unrelated uncommitted repo drift untouched. No PII/secrets.
Status: HANDLED 2026-09-01 (acknowledged by Hermes, verified: 19/19 tests pass, route live, commit 5c9e4f44 on feature/dental-triage)
Dental triage built per triage_configuration_plan, committed on
feature/dental-triage (commit 5c9e4f44, pushed to Forgejo), deployed to
VPS1 demo + VPS2 production. All green.
WHAT WAS BUILT:
dental/ai_triage.py
appointment_create view/template): red-flag Q1 → HARD STOP (bookingscore_to_tier (1→ROUTINE, 2-3→NON_URGENT, 4→URGENT, 5→EMERGENCY).DentalSlotHold model (5-10 min); get_available_slotsdental/slot_hold.py helpers.triage_dashboard + new routes): practiceemergency_slotsemergency → staff callback request (202). PWADentalBookView.vue shows "Request a callback" instead of slot picker.ingest_wildstash.py command: parses 2,494-rec dataset, validates tierdental/tests_triage.py (19 tests, isolated sector_client_testVERIFIED LIVE: hard-stop blocks booking (0 created), urgent books w/ tier
URGENT + needs_confirmation, triage dashboard 200 w/ checker, PWA emergency
gated to callback on both VPS1 demo + VPS2 production.
NOTE: existing unrelated uncommitted drift in dental repo left untouched
(tasks.py/minio/radiograph Phase 8 work, README, dashboards, etc.).
OPEN ITEMS for a future round (from plan §11, answered): symptom checker is
staff-only (PWA parked); URGENT carries needs_confirmation only when booked
outside staff review; is_emergency zones kept as urgency capacity; A&E
guidance configurable per practice (DentalPractice.emergency_guidance).
Photo upload and AI triage remain parked per plan.
Docs: user-facing manual update for the booking/triage screens is yours
(Hermes) when convenient. No PII/secrets in this note.
Status: HANDLED 2026-08-30 (acknowledged by Hermes)
User flagged that /dental/seats/ was restored on the dental app. It is
VeriPath's internal seat-licensing page (the practice's monthly fee to
VeriPath for seats) — NOT part of the practice's clinical/admin workflows.
The user removed it previously and does not want it back. Removed by OpenCode
(branch feature/remove-dental-seats, commit d9d58d3b): the seat_management
view, its URL, the base_dental.html nav link and seat_management.html
template. Deployed to VPS1 + VPS2 (dental_app restarted; route now 404s).
Please do not reintroduce this UI in any future work. The underlying
PracticeSubscription model and the 'SEATS' Invoice type remain intact
(billing/accounting data only — also leave those unless asked).
Context: the previous handoff items are all HANDLED/DECLINED (patient record
build + portal household fix deployed; smart scheduling DECLINED 2026-08-30
per ai_features_justification). No PII.
Status: HANDLED 2026-08-30 (acknowledged by Hermes)
User decision 2026-08-30: the Automated Scheduling / no-show prediction
feature does NOT meet the AI justification standard
(agents/ai_features_justification). The Phase 0/1 risk engine is a
deterministic weighted-scoring process (DNA rate, lead time, appointment
type, time-of-day, provider, NHS/private) — checklist Q1 (plain software
automation) and Q2 (repetitive/predictable) fail, so AI is not justified.
The development/projects/dental_app/dental_app_agent_scheduling_plan and
its open questions are parked/not active; do not start it unprompted.
Everything else in the from-hermes inbox is now HANDLED/DECLINED (patient
record build + portal household fix deployed; seats UI removal; SNOMED
work; triage implementation 2026-09-01). No PII/secrets in this note.
Status: HANDLED 2026-09-23 — both landed and verified live by Hermes: Part 2 as 27dc604 on the pharmacy
application (migration pharmacy_medicines.0001_initial applied on the default and the pharmacy tenant, app healthy),
Part 1's nit as 45b8dd8 on gp_booking_app. All six spec §5 criteria pass on the deployed code — evidence on the
spec page §7 and in from-hermes. Module tests re-run by me: 18 OK. Nothing handed back.
Part 2 — the pharmacy-side view. pharmacy_booking_app, branch feature/pharmacy-medicines @ 27dc604
(off 9722205). A new app pharmacy_medicines carries managed=False mirrors of just the tables the view
needs (source, release, AMP, AMPP, price, GTIN). It is registered in GLOBAL_APPS so central reference rows
always route to the pharmacy app's default connection — never a sector or client DB — and in
INSTALLED_APPS.
/medicines/ — Product information, in the nav for every signed-in pharmacy role (reusesPharmacyStaffRequiredMixin; no new permission). One name search (3+ chars, capped at 25, server-side) with/medicines/pack/<pk>/ (AMPP code, GTIN(s), price with basis,£0.00.Measured, not assumed — against the real 187k-row store (VPS1 copy): paracetam returns the 25-row cap
in 148 ms; a no-match term 157 ms; the label and detail verified live (release 3170926, GTIN
5000309007767, £1.02 "NHS Indicative Price"). No index added: pg_trgm is already installed, so a
GIN trigram index on reference_data_dmdampp.name (a platform migration) is the obvious first modification
if ~150 ms is felt.
Tests: pharmacy_medicines 18 new; the full pharmacy suite (now including it) is 461 OK (was 443).
scripts/run_pharmacy_tests.sh now includes the app.
Part 1 nit (gp_booking_app, feature/reference-feed-status @ 45b8dd8). The "Last successful fetch"
line now shows only where a source has a routine; for dm+d it states the truth — "never — the live release
was imported manually (21/09/2026)" — rather than a bare "never" beside a green Current. The health table
shows "—" for the two feeds with no routine. reference_data suite 73 OK (1 skipped).
Deploy: Part 2 is the pharmacy application (pharmacy_app, repo bind-mounted at /app — commit and
restart); Part 1's nit is gp_booking_app (restart). Forgejo is unreachable from the app host as before, so
both commits travel as git bundles.
Status: HANDLED 2026-09-23 — deployed to the app host and verified live by Hermes:
/root/work/gp_booking_app fast-forwarded 82871c4 → 91b9399 via a git bundle, reference_data.0005 applied on
restart ("Applying reference_data.0005_referencesource_routine... OK"), app healthy. Every spec §4 acceptance point
passes; one wording nit came back in from-hermes. Full record: spec page §6.
Branch: feature/reference-feed-status @ 91b9399, branched off
feature/central-reference-data-phase2 (82871c4) — phases 1 and 2 are not on dev, so the branch
carries them; only 91b9399 is new. 73 tests OK (1 skipped) via
scripts/run_reference_data_tests.sh; makemigrations --check clean.
What it does (spec /en/development/projects/pharmacy_app/build-records/reference-feed-status):
ReferenceSource.routine = trud_pull / none (default none). The seed sets dm+d trud_pull,none; 0005 also backfills existing rows so the live page iskey_status() returns None unless source.uses_credential, so the two monthly feeds can neverTRUD_API_KEY unset; the key is shown separately asaction vs info; informational is a plain blue note, never a fault style.Please verify live (spec section 4 acceptance): with no key set, the only key-naming alert is
dm+d's, and Drug Tariff / Price Concessions read Not automated yet with no warning anywhere and
no key text; dm+d reads Current (release 3170926, published 21/09/2026, next expected
28/09/2026); the four screens stay 403 to anonymous and render for a platform admin; the landing
page carries the one-line summary and the per-feed line. Flip checks: dm+d reads Release overdue
when the expected date passes with a key present, and Not running — no key when the key is
absent and no current release exists.
Deploy: as before — the app host is a git checkout bind-mounted at /app, Forgejo unreachable,
so carry 91b9399 as a git bundle and restart (the entrypoint applies 0005). Nothing external is
needed.
82871c4)Status: HANDLED 2026-09-23 — both fixes are now redeployed to the app host and verified live by Hermes
(/root/work/gp_booking_app fast-forwarded 2301095 → 82871c4 via a git bundle, containers restarted). The
publication-date derivation is proven on the real TRUD archive name; the second nit was misreported by me — the
pre-fix code already recorded the attempt via its blanket finally save, so nothing observable changed. Details in
from-hermes.
Both items from your deploy note are fixed on
feature/central-reference-data-phase2 (82871c4, pushed):
import_dmd_release and the upload path no longer leave publication_date…_20260921000001.zip, or an ISO date), then the manifest's ownRetrieved: date, and the command warns if neither is available. ThereleaseDate).ReferenceFetchAttempt explicitlycall_command).feature/central-reference-data-phase1 (438e565) is unchanged. 61 tests OK;
the guarded real-release test still passes. No behaviour you rely on changed.
On redeploy, reference_data_fetch --source dmd with a key should import and
publish, and a keyless run should leave a not_configured attempt row visible
on the health page.
Status: HANDLED 2026-09-23 — landed and verified live by Hermes (438e565 then
2301095; release 3170926 published, checker green, consent gate exercised, phase 2
surfaces live). Two nits came back in from-hermes: the CLI import leaves
publication_date null, and an unconfigured fetch writes no attempt row. The real
TRUD pull still needs the account.
Branch/commits (gp_booking_app, both pushed):
feature/central-reference-data-phase1 @ 438e565 (deployable now),
feature/central-reference-data-phase2 @ 2301095 (branched off it).
1. The redaction blocker is fixed (phase 1 branch). The three tracked
files no longer carry a literal value: appointments/views.py reads
settings.NHS_NOTIFY_API_KEY (and skips the send without marking a failure
when it is unset), dsp_clinic/settings.py uses CHANNEL_LAYER_URL, and
tenancy/migrations/0002 seeds db_password from settings.DB_PASSWORD.
Host-local config/secrets/env.production additions for Matthew:
CHANNEL_LAYER_URL (the authenticated Redis URL) and NHS_NOTIFY_API_KEY.
The ***REMOVED*** values left in tests/*.py are sandbox keys in test-only
scripts, not live paths.
2. Phase 2 — the weekly routine (phase 2 branch).
trud_client.py — TRUD's release-list and download endpoints, built to thehttpStatus 400 invalid key, 404 bad item/not subscribed.TRUD_API_KEY, never logged.pull.py — list latest → no-op if already held → download → verify theReferenceFetchAttempt (success / already held / no release / key invalid /health.py — live-release age, next expected publication by cadence,REFERENCE_DATA_ALERT_EMAILSretention.py + reference_data_purge (D4) — keep the live and previousreference_data_fetch, reference_data_purge.TRUD_API_KEY (Matthew's account-derivedREFERENCE_DATA_ALERT_EMAILS. TRUD_ITEM_ID defaults to 24.Local evidence: scripts/run_reference_data_tests.sh → 55 tests OK
(1 skipped: the guarded real-release test, which also passes on its own —
REFDATA_DMD_PATH=… → 1 test OK, reference_data_check "All checks passed").
The one thing not verified locally: the live TRUD call. TRUD_API_KEY
is not in my environment, so trud_client/pull are tested with mocks against
the official schema. The archive layout is handled both ways — if the zip
carries a MANIFEST.txt it is verified per-file, otherwise the published
archive SHA-256 is the anchor and per-file hashes are recorded. Please
confirm the real archive on the first live fetch (does it contain a
MANIFEST.txt?); the checksumFileUrl/.asc signature and GPG verification
are deliberately deferred (transport integrity is the archive hash over TLS).
To deploy and verify: land 438e565 first (with the two env names added
host-locally), then 2301095; manage.py migrate (reference_data 0004),
seed_reference_sources, collectstatic, restart. Then either the Fetch
now button or python manage.py reference_data_fetch --source dmd, and check
the health page: attempt result, live release, next expected date, key status,
and that a second run is a no-op. Rollback and purge are exercised by the
55 tests.
Status: HANDLED 2026-09-23 — superseded by the two notes above: the redaction was fixed (438e565), phase 2 (the weekly routine) was built, and both were deployed and verified live by Hermes — the record is plan §10.
Spec: Central Reference Data
(§5.1 store, §5.2 upload flow, §5.12 management view, §6 phase 1).
Branch: feature/central-reference-data-phase1, commit 766a46e, in
gp_booking_app — pushed to origin, not deployed (Hermes deploys and
verifies live).
What is built
reference_data app, added to INSTALLED_APPS and to GLOBAL_APPStenancy/routers.py), so its tables live in the central default DB. Thedb_constraint=False (the dashboards precedent).ReferenceSource (licence register + consent flags),ReferenceRelease (status, fingerprint, integrity, licence snapshot,ReferenceReleaseFile, ReferenceReleaseEvent (theauditing.AuditLog routes to sector_gp, so it getsiterparse; money as integer pence; a zero is neverPRICE_BASISCD translated from the lookup file.verify → preview → confirm → publish; SHA-256 and byte/infrastructure/reference-data/ (nav entry added in_nav.html and the base navbar), every view behindPlatformAdminRequiredMixin; licence register with a consent gate that blocksseed_reference_sources, import_dmd_release, reference_data_check.Local evidence (already run on VPS1)
scripts/run_reference_data_tests.sh — 33 tests OK (1 skipped: thedsp_clinic/test_settings.py (single DB,2026-09-22 imported through the same code: 187,156To deploy and verify
python manage.py migrate (creates thereference_data tables in the central DB), then seed_reference_sources,collectstatic, restart gp_booking_app./srv/datasets/dmd/2026-09-22 folder, upload at/infrastructure/reference-data/upload/, review, confirm, publish). If theimport_dmd_release <dir> --publish runs the identical lifecycle.reference_data_check --release <id> --path <release dir> should printChecker disagreements, for the record: no app behaviour was changed to
please a checker. The VMP→VTM closure treats an absent link as data (above),
and the real-release test uses the real create_verified_release path so its
integrity check compares against stored file rows.
Once it is deployed I will re-run the suite and you can verify live; the plan's
only remaining Phase 1 work after that is nothing (Phase 2 waits on the TRUD
account, D12).
Status: HANDLED 2026-09-22 — deployed by Hermes and verified live 49/49;
the record is on
supply-orders-pricing-phase4-value
(and your W4 answer now sits on that page as its W4 section).
Hermes, 2026-09-22 — deployed and verified. feature/pharmacy-order-value
pushed; the app engine fast-forwarded dev to 9722205; pharmacy_stock.0007
applied on the apex (default and sector_pharmacy) and the client tenant;
all three containers restarted (the repo is bind-mounted — compose up -d alone
does not reload the code); your 443 tests re-run by me on the engine, OK. New
verifier /root/work/veripath-ops/scripts/verify-pharmacy-order-value.py — live
OIDC, no mocks — 49/49, its --reads-only mode through the boundary wrapper
(audit / files / rows / cache unchanged), and the regressions re-run after
stage 1's import: stage 1 35/35, stage 2 24/24, stage 3 24/24. The
three verifiers you flagged as un-runnable locally all pass against the deployed
tenant; none of them needed changing for stage 4.
On the live tenant: the build screen carries all three value states at once
(£35.00 with no minimum → silent; an unpriced line against £150 → "not known, so
it is not checked"; £25.28 against £150 → "£25.28 of a £150.00 minimum — £124.72
short. Advisory only; the order can still be created."); the short draft was
created anyway at the submitted quantity with no line added or changed; the
pre-stage-3 order reads "Value not known" on the list and the detail; a partly
priced order reads £30.40 (partial — 1 line(s) without a price) and is not
compared to the minimum; the export of a valued order is still
ProductCode,Quantity with no price or value; the Counter Assistant is refused
the build screen, the orders list and an order page. Full evidence on the page
above.
One thing only your seed can fix — see the follow-up note in from-hermes.
Branch: feature/pharmacy-order-value (off abd61de), commit 9722205.
W1 — value from the snapshots. order_value.py gives full / partial / none;
Order.value_summary() reads only the stage 3 line snapshots. The order list and
detail now read £X, £X (partial — N line(s) without a price), or Value
not known — never —, never £0.00. No new tables, no backfill.
W2 — the minimum advisory. Per supplier group on the build screen and once on
the order detail: "£X of a £Y minimum — £Z short" when below; silent on a blank
or met minimum; "value not known / partial, so it is not checked" otherwise. It
never blocks, changes a quantity or adds a line — a short order still creates.
Supplier.minimum_order_value help text corrected in the same commit
(pharmacy_stock.0007).
W3 — copy. The model help text, the build-screen note and the supplier form
all say the minimum is the wholesaler's contractual threshold, shown for
judgement, not enforced. C6 guide deferred to the post-deploy pass (it is
still pre-stage-3 in wording, so it needs the routing step too).
W5 — tests/boundary. 443 tests OK. Full/partial/none; advisory
fires/silent/unknown and never mutates a line; Counter Assistant refused every
order surface; the export file still two columns and value-free; reads write
nothing (no ERPNext, no cache write).
Verifiers. I cannot run the three live verifiers locally (they need the
deployed tenant). Stage 4 does not invalidate them: the ordering verifier's
detail checks (Value, Routed by price, overridden) still hold, and the
build-screen parser is unaffected by the added advisory text. If any checker
turns out to need changing, it will be the checker, not the app.
W4 — the margin question: the answer (no code).
What reimbursement price can we legitimately display per stock item? Today
none, per item. The only reimbursement figure in the system is computed per
endorsed dispensing token by pharmacy_accounting.services.compute_expected_reimbursement,
which reads a price list in ERPNext (PharmacyERPNextConfig.reimbursement_price_list,
default "NHS Reimbursement") plus a configurable professional fee. That list is
seeded with synthetic demo values against separate ERPNext items
(DEMO-REIMB-AMOX-500, DEMO-REIMB-IBU-400 in pharmacy_accounting/demo.py)
that are not linked to the stock items and whose synthetic SNOMED codes do
not match. We hold no NHS Drug Tariff and no price-concession feed. dm+d
is staged but unread, and its AMPP PRICE is a manufacturer list price, not a
reimbursement rate. Recommendation: route 1 now — the pharmacy supplies its
own per-item reimbursement figures, stored client-side per tenant, labelled
as the pharmacy's own figure, not the NHS tariff — and route 2 (license
the Drug Tariff + concessions) as the real fix. Never dm+d PRICE, never the
synthetic demo list, never central.
What links a bought pack to what was dispensed? Nothing does today. The
stock item is the pack bought; reimbursement is for what was dispensed.
StockItem carries pip_code / barcode / pack_size; the reimbursement list
is keyed by a different ERPNext item; SnomedItemMapping links a prescribed
SNOMED code to a stock item but is not the reimbursement key. A margin needs an
explicit reimbursement figure keyed to the StockItem with a basis
(per pack / per unit, the same PriceBasis stage 1 captured) and a source
label, reconciled against pack_size. Where the pack and the reimbursement
unit differ, the column must say not comparable, never silently scale.
What the column says in the three states. No figure → "No reimbursement
price" (never blank, never £0.00); basis unstated or pack/unit mismatch →
shown with its basis but marked not comparable (the stage 1/2 NOT_STATED
rule); comparable figure → the margin, with its source and date and the same
freshness treatment, and "not known" whenever the purchase price is stale or
unknown.
Conclusion: do not build the column now — it would compare a fabricated demo
price against a real purchase price. The precondition is Q1 route 1 (a
pharmacy-supplied per-item figure, client-side, with basis and source) or route
2 (licensed tariff); when that exists the column is a small stage of its own,
reusing order_value and price_view.
States to check live on test-client-pharmacy (after re-running both seeds):
build a draft below the seeded £150 minimum (e.g. a group worth £5) and see the
advisory and that it still creates; build one above it and see it silent; open
ORD-20260921-D123C9 (pre-stage-3) and see Value not known, not £0.00; and
confirm the Counter Assistant is refused the order surfaces.
Once deployed, verify as the spec sets out and re-run the stage 1/2/3 verifiers.
I have not deployed it.
Status: HANDLED 2026-09-22 — deployed by Hermes and verified live 24/24; the record is on
supply-orders-pricing-phase3-ordering.
The stale-data follow-up in the note is closed by the same commit: DEMO-STALE-001, DEMO-ONEDAY-001 and the
stale-only supplier Four make every freshness state reachable, and the stage 2 verifier now passes 24/24 too.
Hermes, 2026-09-22 — deployed and verified. feature/pharmacy-price-ordering pushed; the app engine fast-forwarded
dev to abd61de; pharmacy.0019 and pharmacy_stock.0006 applied on the apex and the client tenant; both seeds
re-run. New verifier /root/work/veripath-ops/scripts/verify-pharmacy-price-ordering.py — live OIDC, no mocks —
24/24:
DEMO-IBU-400, qty 32, unit_price 1.0500, PER_PACK, from Wholesaler Three, as at 2026-09-21) — by value;ProductCode,Quantity, code,quantity, no price text, named for the supplier andORDER_ROUTED carries the counts and the value (1 line(s), 0 kept the proposal, 1 overridden, 0 with no usable price; value £33.60); the Counter Assistant is refused the build and the orders list (403); the position cache isBranch: feature/pharmacy-price-ordering (off 7ddddf1), commit abd61de.
Built to supply-orders-pricing-phase3-ordering,
with model (A) (per-row routing, per-supplier drafts) and the deliberate-override starting position:
OrderLine gains unit_price / price_basis / price_recorded_at / price_supplierpharmacy_stock.0006); Order gains routing_summary; the value is derived on read and theorder_routing.py holds the routing rules in one place: usable = matched + PARSED + statedORDER_ROUTED (pharmacy.0019) with kept / overridden / fallback counts; ORDER_CREATED stays.seed_pharmacy_stock now seeds DEMO-STALE-001 (only aDEMO-ONEDAY-001 (only a one-working-day price), and seed_pharmacy_pricesStates to check live on test-client-pharmacy (after re-running both seeds):
DEMO-STALE-001 shows Stale (struck-through, marked not usable);DEMO-ONEDAY-001 shows One working day old; DEMO-IBU-400 still best £0.79 (Current).DEMO-STALE-001 falls back to itsTests: scripts/run_pharmacy_tests.sh — 431 tests, OK.
Note for the stage 2 verifier: its "no price on the order-building screen" check is
superseded — stage 3 deliberately puts the proposal and prices on the build screen. The
correct regression is now "the export file stays two columns and price-free", which the
stage 3 tests assert.
Once deployed, verify live as the spec sets out (the routed example, an override, the audit
counts, a byte-compare of the exported file, and a re-run of the stage 1/2 verifiers). I have not
deployed it.
Status: HANDLED 2026-09-22 — deployed by Hermes and verified live 22/24 (the two failures are a demo-data gap,
not a rendering defect); the record is on
supply-orders-pricing-phase2-visible.
One follow-up is open in from-hermes: make the stale state reachable on the reports.
Branch: feature/pharmacy-price-visible (off 1a01d8c), commit 7ddddf1.
Built to supply-orders-pricing-phase2-visible:
StockSettings.bank_holidays (pharmacy_stock.0005), editable in admin. Working-daypharmacy_stock/price_view.py — the one price-lookup service: the latest live file perNOT_STATED price shownREPORT_COLUMNS)./stock/suppliers/<pk>/ page (supplier profile + price-file history, linking into stage 1'sseed_pharmacy_prices now seeds the holiday list and three synthetic suppliers coveringPER_PACK, so the columns and all three statesStates to check live on test-client-pharmacy (after seed_pharmacy_prices --tenant test-client-pharmacy):
DEMO-IBU-400 (PIP 2345678) has three suppliers: £0.79 (Current,NOT_STATED / No supplier data / Invalid price row are covered by the tests (the seededPER_PACK).Tests: scripts/run_pharmacy_tests.sh — 414 tests, OK.
Once deployed, verify live as the spec sets out: the report columns and the three freshness
states (including the backdated file), the supplier detail history against stage 1's counts,
the Counter Assistant 403 and no price value in any page they can load, and the read
boundary (audit count unchanged, ERPNext untouched, cache unchanged, no prices on the
order-building screen). I have not deployed it.
Hermes, 2026-09-22 — deployed and verified. feature/pharmacy-price-visible pushed; the app engine fast-forwarded
dev to 7ddddf1; pharmacy_stock.0005 applied on the apex and the client tenant; the demo prices re-seeded
(four files, three suppliers). Verifier: /root/work/veripath-ops/scripts/verify-pharmacy-price-visible.py
(live OIDC, no mocks) plus a read-boundary probe.
Current; the "not usable for ordering" note; no £0.00; supplier list → detail links; the detail history showsSuperseded entry; the Counter Assistant is refused the detail page (403), still reads bothCurrent is reachable on the reports, which contradicts the "all three states arefrom-hermes: either seed a case where the best price is old (an item no other supplierStatus: HANDLED 2026-09-22 — deployed by Hermes and verified live; the full record is on
supply-orders-pricing-phase1-import.
Branch: feature/pharmacy-price-import (off dev), commits 6b2c38c
(price-file import, stage 1) and 1a01d8c (the order-build copy nit from your
2026-09-21 note).
Built to supply-orders-pricing-phase1-import:
pharmacy_stock.0004 (SupplierPriceFile, SupplierPrice) and pharmacy.0018
(PRICE_FILE_IMPORTED / FAILED / SUPERSEDED); PharmacyPriceImportRequiredMixin
(Business Manager only) with read surfaces on PharmacyStockManageRequiredMixin
and the Counter Assistant refused all three price routes (403); price_profiles.GENERIC_HINTS
(hints only — no invented layout); price_import (header required, delimiter sniff,
BOM, 5 MB / 50,000-row caps, leading zeros preserved, pip_code then barcode, no
fuzzy matching, prices never zero-filled or rounded, every line kept, supersede keeps
the record and deletes the earlier rows); the BM-only two-step import screens, list
and detail; admin; and seed_pharmacy_prices with two checked-in synthetic CSVs
imported through the real service. No ERPNext client is constructed and no
StockPositionCache row is written.
Example files and the counts they produce (seed_pharmacy_prices --tenant test-client-pharmacy):
synthetic-wholesaler-demo-price-list-01.csv → 6 read, 3 matched, 1 unmatched,0123456 (leading zero, £1,234.56 parsed), 2345678,DEMO-BARCODE-0003 by barcode. Unmatched: 9999999. Rejected: the duplicate0123456 (first occurrence kept) and the non-numeric price on DEMO-SNOMED-999.synthetic-wholesaler-demo-price-list-02.csv (same supplier and price_date) →SUPERSEDED,Tests: scripts/run_pharmacy_tests.sh — 393 tests, OK.
Hermes, 2026-09-22 — deployed and verified. feature/pharmacy-price-import pushed;
the app engine fast-forwarded dev to 1a01d8c; pharmacy.0018 and pharmacy_stock.0004
applied on the apex and on the client tenant via migrate_client --slug test-client-pharmacy.
35/35 HTTP checks passed on test-client-pharmacy (script:
/root/work/veripath-ops/scripts/verify-pharmacy-price-import.py), plus database and
boundary reads:
tc.bm.one: the mapping step showed the file's own headers and samples; file 019999999 named as0123456 rejectedtc.rp.one and tc.tech.one read the list and detail (200) and are refusedtc.ca.one is refused all three price surfaces (403); only the BusinessPRICE_FILE_IMPORTED and 2 × PRICE_FILE_SUPERSEDED, all attributed to Besstc.bm.one) with the counts in the detail; nothing else wrote an audit row today.seed_pharmacy_prices --tenant test-client-pharmacy reproduced exactly the counts aboveOne loose end handed back to Matthew, not a defect: stage 1 as built accepts
price_basis = NOT_STATED (the spec's default), while the stage 2 review decision (C2) says an
import with an unconfirmed basis should be refused. Settled 2026-09-22: the import is not
refused for an unconfirmed basis — the price is shown with its basis beside it and never as
comparable, so stage 1 stays as built and stage 2 carries the basis on screen.