Archived from
agents/handoff/from-hermeson 2026-09-12 (housekeeping — inbox token-efficiency pass peragents/handoff/index).
All notes below are HANDLED — kept as the audit trail.
Further archive pass 2026-09-15 (housekeeping — all 2026-09 pharmacy notes HANDLED).
Further archive pass 2026-09-18 (housekeeping — 10 HANDLED notes moved from the live inbox).
Further archive pass 2026-09-21 (housekeeping — 4 HANDLED notes moved from the live inbox: the three
2026-09-19 pharmacy tickets and the 2026-09-16 staff-screen / credential note).
Further archive pass 2026-09-26 (housekeeping — 15 notes moved from the live inbox: the superseded
hold note, the 2026-09-25 TRUD-archive note, the 2026-09-23 feed/reference-data records and the earlier pricing
records).
Further archive pass 2026-09-27 (housekeeping — 11 HANDLED notes moved from the live inbox: the phase-7
build and verification records, the pack-link records, the Part II group-rule records, the phase-6 deploy
re-verification, and the phase-4 and phase-5 build briefs). The live inbox keeps only its OPEN notes.
Further archive pass 2026-09-29 (housekeeping — 9 HANDLED notes moved from the live inbox: the file
contract and its two ruled decisions, the 2026-09-28 live-pass trio and its two build briefs, the stock item
page's raw cell dict, the phase-8 build and closure pair, and the 2026-09-22 stage-4 verification). The live
inbox keeps only its OPEN notes — 7 held: 6 fully open and 1 partly closed. The moved block runs
2026-09-29 → 2026-09-22.NOTE (2026-09-29): this page is now ~357K characters. The next September pass should start a second
page for the month rather than grow this one further — the 2026-09-27 pass flagged the same threshold.
In passing: the oldest moved note (2026-09-22) carried a baked-in wrapper footer (a stray rule line and a
secondPage:line, an artefact of an earlier upsert); it was stripped as it moved rather than copied in.
The inventory at the pass: 17 notes held, 11 HANDLED and moved, 6 kept — 5 fully open and 1 partly closed.NOTE: this page is now ~323K characters. Read it in slices rather than whole, and expect the next
September pass to want a second page for the month.
Newest on top (original order preserved).
Status: HANDLED 2026-09-29 — built, deployed and live (085f7ba); both rulings implemented (the operator-declared headerless mode, and the samples committed as repo files). Matthew delegated both to me at the end of the
session ("I'm not sure so just do as you recommend"), so the reasoning behind each ruling is written out below:
nothing here is a guess you have to reconstruct.
Ruling 1 — §3.1 (the headerless price file): build the positional fallback, but only as something the operator
declares. The app must never decide. My recommendation was B (the positional fallback) with A (refusal) as the
interim guard; the way to get the value of both is to make the human the detector, and to turn today's silence into a
named consequence the operator has read and accepted.
headers.index() mapping and everymatched + unmatched + rejected == rows_read) — for a declared headerless file rows_read is every data line,synthetic-no-header-positional.csv. Today it reads 2 linesRuling 2 — the sample delivery route: the repository, not a web folder. The four sample files and their README.md
ship as ordinary repo files in pharmacy_stock/sample_price_files/, committed with your change rather than left
untracked, so delivery to Matthew is an ordinary repo download and the files are version-controlled for you. Do not
create a static folder for them: the provisional one I had half-built no longer exists (measured today — the directory
holds only the Django static directories and the sample URL answers 404), so there is nothing to remove, and these
are import fixtures rather than shipped assets. Please commit them with the change.
Status: HANDLED 2026-09-29 — built, deployed and live (085f7ba): the export change (§2) and all three import items (§3.1–§3.3). The export change and two import items were decided by Matthew
2026-09-29 and are yours. §3.1 (the headerless price file) is NOT decided — do not build it until he rules.
Everything below was measured on the live test client on 2026-09-29 read-only: nothing was written to any tenant.
1. The file contract, as Matthew stated it (2026-09-29). An order file is universally a flat two-column grid
answering two questions for the supplier's warehouse system: which item (the code column) and how many outer packs
(the quantity column). Comma separated — never tabs or semicolons. One pack size per row: the same drug in two pack
sizes is two rows, because they carry different codes. The quantity is always whole outer commercial packs, never the
individual pill count. No header row, and no supplier id — the file is uploaded into the portal, so the supplier
and the pharmacy are known from the login. His name for the out-of-the-box default: "Standard UK Wholesale Export".
2. W1 — the default export profile becomes headerless (DECIDED, build it). Today
order_profiles.ORDER_PROFILES['GENERIC'] carries 'headers': ['ProductCode', 'Quantity'], and
OrderExportView.post's stream() writes them as line 1, so the file we hand a pharmacy today begins:
ProductCode,Quantity
2345678,32
The contract is that same file with the first line gone. Two parts must not change: the code column is already
the standard (PIP first, barcode fallback — code_field), and the quantity is already whole packs, with a non-whole
or non-positive quantity refused before export. Work:
pharmacy_stock/order_profiles.py: keep the key 'GENERIC' (it is stored on Supplier.export_profile, so a newlabel to name the standard; set 'headers': [], andOrderExportView.post: write a header row only when the profile has one — never an empty first line.pharmacy_stock/tests.py lines 919, 2088, 2602) to assert the<code>,<quantity>), and add one assertion that the file carries no header line at all (anfilename_for() or anything the portal signal reads: the stage-1 verification proved the export'sContent-Disposition is byte-identical to the signal's data-file, and dropping the header row cannot affect it.3. Three findings on the import side — four synthetic files, replayed read-only. The files are in
pharmacy_stock/sample_price_files/ with a README.md carrying every count. They are not seed inputs:
seed_pharmacy_prices reads its own explicit list from its own folder. Each was read through price_import.read_file
and replayed through the module's own parse/match/reject rules without calling import_file, against the seeded
tenant test-client-pharmacy.
| File | Shape | Measured |
|---|---|---|
synthetic-portal-export-five-column.csv |
comma, 5 columns | read 7 — matched 3, unmatched 1, rejected 3 |
synthetic-renamed-headers-decoys.tsv |
TAB, renamed headers | read 4 — matched 2, unmatched 2 |
synthetic-no-header-positional.csv |
comma, no header row | read 2 — the first line was consumed as the header and lost |
synthetic-semicolon-decimal-comma.csv |
semicolon, decimal commas | read 3 — matched 3, and every price stored ×100 |
3.1 A headerless price file is not refused — its first line is silently eaten. DECISION NEEDED; DO NOT BUILD YET.
read_file takes the first non-blank line as the header, so a three-line headerless file comes back as
headers=['0123456', '1.20'], rows=2: the first line is gone, and an import would report a clean 2 matched,
0 rejected with nothing anywhere saying a line went missing. PriceImportError("No header row was found in the file.")
fires only when the file has no non-blank line at all. I am correcting my own earlier record: I had written that
the import refuses a headerless file — it does not. Why this is a design question rather than a tweak: headerless
files are real (the Fourth/TradeSimple order-confirmation spec requires the first record to be data, and §1's export
standard is headerless); SupplierPriceFile records the mapping as column names, which a headerless file does not
have, so a positional mapping is a model question (column indexes and a saved positional profile) as well as a
parse one; and the guard must not disturb the headered path, which every live file uses today. Options put to Matthew:
(A) detect it and refuse with a clear sentence — no data loss, no new model, smallest change; (B) offer the
positional fallback on the mapping screen ("this file has no header row — tell us which column is the code and which
is the price") and record that mapping positionally. I recommend B with A as the immediate guard, because it is
the same mapping the return leg will need. Await his ruling; start neither.
3.2 A decimal comma is stored ×100, with a clean success report (DECIDED, small). parse_price() does
text.replace(',', ''), so 1,20 is stored as 120, 0,89 as 89 and 3,50 as 350, with every row reported
matched. Because DELIMITERS includes ;, a semicolon-delimited EU-style export is a file we invite and then
corrupt a hundredfold. Fix, on the no-guessing principle: treat a comma as a thousands separator only where it
groups digits in threes (digits{1,3}(,digits{3})+, optionally followed by a decimal part); any other comma shape is
refused with a named reason (e.g. "the price uses a decimal comma, which is not supported") rather than
reinterpreted. Leave a code note that a per-file decimal-comma choice on the mapping screen is a later decision for
Matthew, not part of this.
3.3 The mapping hints miss the commonest real spellings (DECIDED, small). price_profiles.suggest_columns()
matches a candidate exactly, so the realistic five-column export's PIP_CODE and TRADE_PRICE pre-select
nothing, while DESCRIPTION does. Match on a normalised form (lower-case, non-alphanumerics collapsed) so
PIP_CODE, PIP Code and PIPCode all reach the same hint, keeping the exact comparison as a fallback. This is the
foundation of the mapping layer Matthew wants: hints that suggest, a human who confirms, and nothing recorded that was
not confirmed.
Not yours: guide C6 and the phase-4 value page quote the current exported file verbatim, so the header change makes
them stale — those are mine, one page at a time. Nothing here is deployed. The four sample files are in the working
copy on both hosts and are not committed: take them with the change.
Status: HANDLED 2026-09-29 — all three fixed, deployed and measured live (085f7ba). All three measured on the live test client on 2026-09-28, during the verification of today's
handback and while writing the pricing walkthrough's Stop 1.
1. The deploy does not run the tenant migration — and the whole stock module 500s until it does. Your handback
recorded "migration pharmacy_stock.0009_supplier_portal_fields applied to the client DB", but on the tenant the app
actually serves (sector_client_test-client-pharmacy → DB test_client_pharmacy) it was not applied: every stock
page answered HTTP 500 (ProgrammingError: column pharmacy_stock_supplier.portal_method does not exist) for the
half-hour the deploy was live. migrate_client --slug test-client-pharmacy --plan listed it as pending; I applied it
with migrate_client --slug test-client-pharmacy --app-label pharmacy_stock and every page returned to 200 — my live
pass then passed in full. Ask: make the tenant migration a step of the deploy, using the app's own
migrate_client (the dynamic sector_client_<slug> aliases are not visible to a bare manage.py migrate --database),
and check the tenant after a deploy rather than the apex DB. The apex database has no pharmacy_stock_supplier table
at all, which is why a migration run there proves nothing.
2. The two-line supplier group still does not reach the partial state. The seed change is right in shape —
DEMO-OTC-IBU-200 now prefers Synthetic Wholesaler Two (DEMO) and its alert level is 20. But on the live client the
item's cached on-hand is 48 (a pre-existing ADJUSTMENT movement, batch DEMO-OTC-IBU-B1, dated 2026-09-14),
so reorder_suggestions() skips it (on_hand > low_stock_level) and it is never proposed. The order build's
Synthetic Wholesaler Two group therefore holds one line, priced — DEMO-IBU-400 at £0.79, £25.28 against the £100
minimum, i.e. the short advisory, not partial — and no screen anywhere carries "£X (partial — N line(s) without a
price)" (checked on the build page, the draft orders and the orders list). The comparison is on_hand <= level, so
the intended 20-against-20 would qualify: the gap is the item's actual cached quantity, not the rule. Ask: make the
item genuinely low from a seed — set its cached position to the level the way the seed already writes the cache for
expired batches, or raise the level above the real on-hand — so a fresh stock+prices seed reaches the state. This is
the same ask as 2026-09-22, now with numbers.
3. A superseded price file's page says every line matched, which is the opposite of what happened. Open
Stock → Price files and look at synthetic-wholesaler-demo-price-list-01.csv for Synthetic Wholesaler (DEMO)
(2026-09-01, Superseded): the counts row correctly reads 6 read · 3 matched · 1 unmatched · 2 rejected, but the
Unmatched and rejected lines section underneath reads:
"Every line matched a stocked item."
The reason is that superseding removes the file's SupplierPrice rows (the counts are kept on the file row), so the
section finds nothing to list and falls into its empty state — but it is the same sentence a genuinely clean file
shows, and it is on exactly the screen whose purpose is answering "why is this item unpriced?". The walkthrough now
has to warn the reader about it. Ask: when a file's status is SUPERSEDED, say so in that section — something like
"This file was superseded by a later file for the same supplier and price date; its lines were removed and only its
counts are kept." — and keep the counts visible. Small, presentational, and it only bites on the screen we point
people at.
Closed from the same handback, verified live by me: the price-file drop zone (all of §7 of its spec, plus the
browser half — a real drop, a two-file refusal, and the page still working with JavaScript off), supplier portals
stage 1 (its spec's §12 carries the record, including the export's Content-Disposition being byte-identical to the
signal's data-file), and the stock item page's raw cell dict. Your four from-hermes notes are marked HANDLED; the
two asks above are this note.
Status: HANDLED 2026-09-28 — built on feature/price-import-drop-zone (522a85a), merged into
feature/pharmacy-session-20260928 @ ee45617, deployed and verified live by me the same day: the drop zone and
the unchanged picker (accept = the form's own .csv,.txt,.tsv), the script the only one on the page with no submit,
no FileReader and no request, a real drop exercised in a browser (file lands, is named, does not submit; two
files refused with the shared sentence), the same file importing with byte-identical counts twice, a .txt accepted,
a two-file POST refused server-side with no record, and the Counter Assistant at HTTP 403. The as-built record
is §9 of the spec. The original brief follows. — authorised by Matthew 2026-09-28 ("let's do it and then it's out of
the way once given to OpenCode"). The brief is
Price File Import — Drag and Drop (Build Spec).
The one thing in it that is not mechanical: this adds the app's FIRST JavaScript. Measured 2026-09-28 — zero
<script> tags across every pharmacy*/templates/ directory, no static JS, one hand-written <style> in
pharmacy/templates/pharmacy/base.html, and the forms are deliberately written to be server-side (a dependent select
is solved in clean() with the comment "the list cannot be filtered before submit without JavaScript"). So the spec
holds the whole job to strict progressive enhancement: vanilla, inline in the template, ~20 lines with a comment,
no framework, no CDN, no build step, and the page must work exactly as it does today with JavaScript off — the
picker and the Upload and map button stay, unconditionally, and the server stays the only authority.
The five work items, briefly — the spec carries the wording:
.drop-zone beside the other components in base.html) — no Bootstrap: the two drop zones that already existtemplates/core/import_dashboard.html, templates/clinical_data/patient_image_upload.html)accept must be .csv,.txt,.tsv — the form's own help text (PriceFileUploadForm.file): the file field hasaccept today, and the obvious copy-paste (accept=".csv") would quietly narrow what the app accepts.form.cleaned_data['file'] yields one value, so a POSTfile silently imports one of them (Django's MultiValueDict returns the last).request.FILES.getlist('file') with the fixed sentence, and create no record.FileReader, no client-side parsing, no preview builtFAILED record, the counts and the role check are untouched.Tests: every existing price-import test stays green (that is the proof the POST path did not move); a two-file
POST is refused with no SupplierPriceFile row; the upload step renders the drop zone and the file input, with
accept equal to the form's help-text list; a .txt/.tsv still uploads; the picker and the button are present
unconditionally in the rendered HTML.
When it is built and deployed, tell me: I verify it live on the test client with the seed's synthetic price files
(one file, two files, a .txt, JavaScript disabled, and a Counter Assistant still refused by the view), write the
dated as-built note, and update one sentence each in the pricing walkthrough's Stop 1 and the pricing page — the
picker path those pages describe stays true, so no guide has to be rewritten.
Status: HANDLED 2026-09-28 — built on feature/supplier-portal-stage1 (22714fe), merged into
feature/pharmacy-session-20260928 @ ee45617, deployed and verified live by me the same day; the record is §12 of
the spec (the portal record on the tenant DB, the ready order's signal, both waiting sentences verbatim, all four
evidence-rule refusals, the block on the order page alone, and the export's Content-Disposition byte-identical to the
signal's data-file). One thing was owed by the deploy, not by the code: the tenant migration — see the top note.
The original brief follows. — authorised by Matthew 2026-09-28.
The stage has opened. The module map's deferred wholesale and procurement ordering row is authorised, and the
route is settled: a local satellite on the pharmacy's own PC rides the technician's already signed-in browser and
stages the order in the wholesaler's portal — it never holds a credential and never submits. Read in this order:
Stage 1 is the mothership side only: no local program, no download, no installer, no endpoint, and nothing that opens
or contacts a portal. Six work items: the supplier's portal_method / portal_target / portal_verified_on /
portal_evidence fields with the evidence rule enforced in Supplier.clean() (migration pharmacy_stock/0009);
the supplier form and detail page; the export filename extracted to order_profiles.filename_for() so the file, the
signal and the tests cannot drift; pharmacy_stock/portal_signal.py carrying SIGNAL_CONTRACT = 1; the inert
#veripath-probe-signal block on the order page (with a human-readable portal line beside it); and the seed — the two
synthetic wholesalers get FILE_DROP and GRID with DEMO-marked evidence, the others stay unconfigured so the
waiting state is reachable from a fresh seed.
Two things the whole stage rests on. The evidence rule: a portal configuration is usable only with the portal's
own address, the date it was seen, and what was seen and by whom — enforced in the model, never in a screen; a synthetic
configuration is marked DEMO (uppercase) and shown as such. And no secret of any kind, ever — no credential,
token, cookie or session material in a field, a template, a download or a log; the signal carries only what the page
already shows.
Do not start stage 2 (the satellite skeleton and the download). It waits on Matthew's answer about the pharmacy PCs
and whether a browser with a local debugging port is acceptable, and it is not in this brief.
Status: HANDLED 2026-09-28 — read live at /stock/items/31/: no dict repr anywhere on the page, and the cell prints
£1.04 — 20% deducted beside Your price: £2.10 Synthetic Wholesaler Two (DEMO) (Per pack). The sibling templates
were already printing cell.text.
pharmacy_stock/templates/pharmacy_stock/item_detail.html, the line under Your price:
<span class="muted">{{ info.cells|last|default_if_none:"" }}</span>
info.cells is a list of dicts, so Django renders the last one's repr and the page shows
{'key': 'reimbursement', 'text': '£1.04 — 20% deducted', 'state': 'ok', 'title': '20% deductible…'} instead of
£1.04 — 20% deducted. Seen live on 2026-09-27 at /stock/items/31/ as tc.tech.one.
Fix: print the field, not the dict — {{ info.cells|last.text }} (or pass a ready-made string in the context).
Please also check the sibling templates (low_stock_report, expiry_report, order_build) for the same
cells|last shape; those render {% for cell in row.price_cells %} and looked right in my live pass, but the pattern is
worth one grep. Nothing else on the page is affected: every figure, rule and release line above it reads correctly.
Status: HANDLED 2026-09-27 (closure record — one small ask at the end, the two-line supplier group; nothing else
is owed on this build)
Your naming question — keep it exactly as you built it. The seven real packs carrying the dm+d names verbatim,
with the deliberately-unbridgeable rows keeping their (DEMO) markers, is right, and I have recorded it on the spec
(§11): the dm+d wording is what keeps the deterministic name + pack route possible on a later month, and a
contractor's catalogue is supposed to read like a catalogue. The demo identity is still carried where it belongs —
the item codes (DEMO-…, unchanged), the tenant, and the two rows left unbridgeable on purpose. These are
public product descriptors and published identifiers: no patient data is involved.
Verified live by me, not taken on report (spec §11 carries the full table):
no_code,no_code_match;DerivedPackPrice row on net, basis and rule text;linked 7 (gtin 7), queued 6, changed 0, and a before/after hash over every link row is3170926 — so a figure on a screen can be defended from the row;veripath-ops (verify-client-comparison-live.py): all seven measured packs show the pack, the figure, the rule,£0.00, and the queue names its reason;order_routing, order_value and order_profilesroute=manual, evidence='linked by hand', operatortc.tech.one and the date on the row, an audit event written, and the resolver left it alone (manual 1,changed 0, the queue 6 → 5). I removed it afterwards so the seeded queue is as §9 designs it;/stock/items/<pk>/ → 302 to OIDC; the link route is stock-manage only.Both of your recorded items are confirmed, and neither is a defect in this build. The 2 pharmacy_stock failures
are date-dependent: I ran the live helpers today (Sunday 2026-09-27) and the seed's "previous working day" is
Friday = 0 working days, so the state is Current where the tests expect One day — on a Monday or Tuesday the
same call returns One day as they expect. The build does not touch the freshness helper or the seed's date
arithmetic (its diff adds items, batches and CSV rows). And the stage-4 partial advisory is indeed still
unreachable — that is the ask.
One ask, and it closes the last live item of the 2026-09-22 note: give seed_pharmacy_stock a deterministic
two-line supplier group with one line unpriced, so "£X (partial — N line(s) without a price)" is reachable from a
fresh seed. The second supplier minimum landed with this build, so that note's short half is now done.
Status: HANDLED 2026-09-27 — built, deployed by revision and verified live (see from-opencode); one question on the §9 seed naming is left there. Spec: The Client Comparison — Phase 8.
Matthew approved the approach on 2026-09-27 and delegated the two open questions to me; both rulings are on the
spec with their reasons (D-C the licensing position, D-D the demo seed) and are his to overrule — build to them
as written unless he changes them.
What this is. The phase-7 derived reimbursement shown beside the contractor's own imported purchase price, on
the screens that already show a price — the low-stock report, the expiry report and the order build all read
price_view, so it is one more column there, plus the stock item's own view. Advisory only, never blocking an order
(phase 7 §5.6). Both the figure and the price already exist; the one missing piece is the identity of the pack.
The bridge — measured read-only on the live store, 2026-09-27. It corrects the shorthand in my own earlier note.
There is no PIP attribute anywhere in the central store (the dm+d models carry gtin and nothing PIP-shaped), so
PIP can never reach dm+d — PIP is the commercial code that matches a wholesaler price row to a StockItem. The
pack route is barcode/GTIN first, name+pack second:
category 2,618 of 3,248 · none_part_two 596 of 598 ·none_group 349 of 377 · none_concession 216 of 216. Exclude end-dated GTINs. A one-shape match loses packsCATDT checksum.Build list — the spec's W1–W8 are the brief; these are the load-bearing ones.
StockItemDmdLink on the client database: the item, the route, the codes (gtin, ampp_id, vmpp_id) and thelink_stock_items_to_dmd — its own re-runnable command, idempotent, recording a dated summary of what it did;price_view (the one place a price answer lives so every screen agrees), stated only whenPER_PACK; a PER_UNIT or NOT_STATED price shows the figure beside the named/medicines/ panel, notmay_publish), never hard-coded.Acceptance (I verify live, as usual): every state in spec §6 renders with no blank and no £0.00; the
linked/queued counts reproduce under my own query and a second run is a no-op; every bridged demo item's figure
equals the stored DerivedPackPrice recomputed from its inputs; an order whose price is above reimbursement still
builds and exports; the label names the pack, the rule and the releases, and no CPE-derived value appears on any
client surface; suites green (pharmacy_stock, pharmacy_medicines, the combined run); nothing written to the store.
Not in this build: the item list or the concession group, the CPE gate, the parked column-per-wholesaler set, and
any margin or valuation figure.
Deliverables as usual — and two cheap ones from last session, both of which cost a cycle then: put the bundle
beside the deployment (I had to copy the last one across), and add the comment on the link model saying the row
records the dm+d release it was matched against while the figure it points at cites the dm+d, tariff and notices
releases — so the next reader does not conclude that rows are missing.
Status: HANDLED 2026-09-29 — stage 4 is deployed and verified, and both seed asks (the short and the partial advisory) are now reachable from a fresh seed. Record:
supply-orders-pricing-phase4-value.
1. The seed cannot reach the short advisory (the one that matters most). Only Synthetic Wholesaler (DEMO)
carries a minimum (£150) and its single line has no price, so the build screen could only ever show "value not
known". I set £150.00 on Synthetic Wholesaler Two (DEMO) through the supplier screen so the short state is
live for Matthew's walk-through — please put a minimum on a second supplier in seed_pharmacy_stock so a re-seed
keeps it (same reasoning as the stage 3 stale/one-day demo items).
2. The partial state is not a screen state either. W1's £X (partial — N line(s) without a price) appears only
when two rows are posted into one supplier group (the view's re-grouping contract). Every group on today's build
screen has one line, so the state is unreachable from the screen itself. A two-line group in the seed would make it
visible; the code is right, the demo data is thin.
3. Your W4 answer is now the spec page's W4 section — moved onto the page above and credited 2026-09-22, so the
spec's own rule ("the verification for W4 is the answer, on this page, with its evidence") holds and the column stays
unbuilt. Correct my condensation if I flattened anything you need kept.
4. Two things from my run, for the record (no action unless you disagree):
--price-date 21/09/2026 collides with its own previous run, so "no supersede warning on a--price-date 22/09/2026 for the 35/35; either keep doing that, or5. Still owed (not yours until after the walk-through): the C6 guide and the Business-Manager guide need
the routing/value wording — deferred per Matthew's 2026-09-22 decision.
No further code is wanted for now. Matthew stopped here for the night: the next session is the walkthrough pages
for stages 1–4 (2026-09-23), then the guide wording above. The plan's only remaining code is stage 5
(retention/purge), which waits on his Q2 answer — so treat the four follow-ups above as the whole of the code side.
Status: HANDLED 2026-09-27 (closure record — nothing owed; the build note below is closed)
Deployed and verified, and your checksum correction is right. Full record: spec §9.3.
Deploy done by me (1d36ddf, migration 0013, the held VMP file copied over from VPS1, the three commands, restart,
reference_data_check clean). All 4,439 figures recomputed independently including the new group path: 0 wrong on
basis, rule, rate and net; the 598 item-level rows unchanged; 263 derived packs are Schedule 1–3 VMPs and none is
left on a category deduction; a second reference_data_control_drugs run reports 0 rows changed; suites 220 (2
skipped) / 35 / 178; verifier 26/26; client surfaces 27/27 — a controlled-drug pack names the group with no
rate applied — and the pack-link verifier 32/32.
Your correction stands and mine was wrong. My brief's 24,483/825 came from a one-shape parse that dropped the
179 entries carrying <CATDT>/<CAT_PREVCD>; the file has 24,662 entries and 964 VMPs in Schedules 1–3, and
139 of the missed ones are in scope — so your reading saved 139 controlled drugs from a category deduction. I
re-checked the file myself before accepting it. Recorded as my error in §9.3.
Two notes for next time, neither a defect: the bundle was not beside the deployment (I copied it), and the
member rows carry the dm+d release while the group rows carry the tariff release — correct, but a comment would save
the next reader a cycle. One thing for a human eye, not a build: the cytotoxic/cytostatic set is curated (869
VMPs) because the document defines no membership; it is tagged curated and reviewable on the queue view.
Status: HANDLED 2026-09-27 — built and handed back for live verification (see from-opencode); the CATCD checksum in the brief is corrected to 24,662 entries / 964 in Schedules 1–3 (179 entries carrying CATDT/CAT_PREVCD were dropped by the earlier measure). Originally: Matthew's blanket approval of the outstanding decisions, 2026-09-27. This is the §9.1
gap from my verification of phase 7 — and the open part of it is smaller than I first reported, because one of my own
claims was wrong and I checked it rather than leaving it: see the correction below.
Where we are. Phase 7 applies Part II's concessionary-price group and its 1,433 matched individual items
(598 packs derive no deduction as a result). The other group rules are stored as data but not applied, so a pack in
one of them currently takes a category deduction where the tariff says no discount is deducted.
The correction, and it matters — dm+d does carry the controlled-drug schedules. I reported "dm+d carries no CD
schedule"; that is wrong. The held release's VMP file carries <CONTROL_DRUG_INFO> with one
<CONTROL_INFO><VPID>…</VPID><CATCD>…</CATCD> per VMP — 24,483 entries — and CATCD maps to the
CONTROL_DRUG_CATEGORY lookup already loaded in the store (0001 Schedule 1, 0002/0003 Schedule 2, 0004–0007
Schedule 3, 0008/0009 Schedule 4, 0010 Schedule 5). 825 VMPs in the release are in Schedules 1–3, and 186 of the VMPs
behind our derived figures are among them. The store's DmdVmp table simply has no column for it and the loader
never reads it — reference_data_dmdvmp columns are vmp_id, vtm_id, name, abbreviation, basis_code, pres_status_code,
df_indicator_code, non_avail_code, invalid, release_id. So this group is closeable from the release we already hold,
with no new source, no new licence and no new fetch.
Build this.
DmdVmp (a new column + migration, loader reads CATCD fromf_vmp2_*.xml), so the CD schedule is available per VMP like any other dm+d attribute. It is a re-load of the heldDmdLookup.none_group), alongside the existing none_concession and none_part_two. TheCATCD (0001–0007).reference_data_dmdvmpingredient →reference_data_dmdingredientsubstance); 38 VMPs behind the derived figures by that route.reference_data_dmdvtm, e.g. Influenza vaccine), which is where these classes actually live; substance evidenceAcceptance (I will verify live, as usual): the CD-schedule packs and the insulin/vaccine/cytotoxic packs derive no
deduction and name the group; the 598 item-level rows are unchanged; the group sets are readable as data on the
platform view with their derivation; the unapplied two groups are named there; a second run is idempotent; suites
green (reference_data, pharmacy_medicines, the combined run); and no scored match decides anything — the queue
carries whatever a rule cannot settle.
Not in this build: any change to the item list or the concession group, phase 8's client comparison, and the two
unidentifiable groups above (named, not guessed).
Deliverables as usual: the bundle or branches, migrations tracked, test files deployed with the code,
bundles beside the deployment, and the counts stated. I extend
verify-derived-pricing.py / verify-derived-pricing-live.py for the new position and run them myself.
Two practical points, added after checking the estate — they will otherwise bite at deploy time.
/srv/datasets there and no f_vmp2_*.xml anywhere on it — the/srv/datasets/dmd/2026-09-22/ (the path in the importer's own docstring is VPS1's).MANIFEST.txt, ~228 MB) if you want thef_vmp2_3170926.xml if you write the field loader as its own command.f_vmp2_<release>.xml and sets the new column on the existing release), and record what it did on the release —A checksum for that loader, measured on the file 2026-09-27 — the <CONTROL_INFO> entries in
f_vmp2_3170926.xml by category code: 0000 23,182 (no CD status), 0002 558, 0003 1, 0004 49,
0005 74, 0006 143, 0008 93, 0009 255, 0010 128 — 24,483 in total, of which
825 VMPs are in Schedules 1–3 (0001–0007) and 186 of those sit behind our derived figures. Assert those numbers in
the tests; a parser that silently drops the block will show up as a zero rather than as a wrong figure later.
Status: HANDLED 2026-09-27 (closure record — nothing owed to you; both build notes above are closed)
Full record: derived pricing spec §9.
Verifier 26/26 reproduced; suites 209 / 35 / 178; all 4,439 figures recomputed independently from their inputs
(0 wrong on basis, rule, rate and net; no row in the brands default without category words); the Part II list matches
the document one for one (1,462 / 1,462, 151 (ALL), nothing dropped or invented); the rate mapping checked against
Part V Table 1's own inclusion criteria (and its sentence "will not apply to any product in Part II" is the rule you
built). New client-surface verifier verify-derived-pricing-live.py 22/22 over HTTPS through OIDC — the £3.61
concession case, a Part II pack at its un-deducted price, the new no_line state, an absent price naming the missing
input, W3 unchanged — and the extended pack-link verifier 32/32.
One item may come back to you as a build, and one correction. The Part II group rules are stored but not applied
(spec §9.1): measured through dm+d's own substance/VTM data, 112 of 4,439 packs (2.5%) — insulins 38, vaccines and
antisera 26, cytotoxic/cytostatic 48 — take a category deduction where Part II says none is deducted, with cold chain,
the CD schedules and the unlicensed-specials route unidentifiable from the store. The error direction understates the
reimbursement. It is with Matthew; do not start it until I brief it. And the spec's §4 count is corrected to
1,462 items (98.0% matched, 29 queued) — my pre-specification figure included one running-header line; your parser was
right.
Status: HANDLED 2026-09-27 — built with the panel-state split in one pass, deployed by revision and handed back for
live verification (see from-opencode): Part II ingested (8 groups, 1,462 items,
98.0% matched), 4,439 derived figures, verifier 26/26 on the app host, suites 209 / 35 / 178. Read the spec first — it is the brief's detail:
The Derived Pricing — Phase 7 (acceptance
criteria are its §7 and I will check them one by one). Build it together with the panel-state split at the top of this
page — same file (catalogue.py + the pack detail template), so do them as one pass.
What this phase is. The store already holds dm+d (3170926), the tariff release 2026-09 with its four parameters,
the concessions 2026-09 (216 lines) and the pack link (216/216 matched). Phase 7 turns that into one defensible
reimbursement figure per pack, and names the rule behind it. It adds no feed.
1. The rule that is currently missing — ingest Part II. The Drug Tariff's Part II ("Drugs for which discount is
not deducted") is published monthly as its own download on the same NHSBSA Drug Tariff pages
(Drug Tariff Part II September 2026.pdf, 154,503 bytes, 27 pages; the media folder is the upload month, as with the full
edition). Its operative sentence: "Contractors who have not received discount in respect of the following products need
not endorse the prescription. The prescription price reimbursement will have no discount deduction applied." It carries
8 GROUP rules and 1,463 individual items — §3(b) and §4 of the spec list both in full.
2026-09), not as a new source: theConcessionPackLink already uses.(ALL) marker on 151 entries) — resolve at product level, and the ruleAPPLIANCES AND CHEMICAL REAGENTS repeats onPart II 09/2026 footer) — leaving them in adds phantom items.2. The calculation (§5):
£0.00.3. Surfaces. The platform pack panel and the pharmacy pack panel show the derived figure, its rule and its releases;
where the client has an imported supplier price for the pack (§5.6) show it beside the derived figure — advisory
only, never blocking an order. The pack panel's four states come from the fix above; no figure renders outside
linked.
4. Acceptance — spec §7, all ten, and these are the ones I will check hardest: the rates read from the release they
claim; the concession pack deriving £3.61 with the rule named; category arithmetic asserted numerically; a Part II item
deriving no deduction with the published condition quoted; net != basis + 152; no zero ever a price; source and release
identity on both surfaces; idempotent (a second run changes nothing); read-only client-side.
Deliverables, in the shape we agreed so I can reproduce everything on the host: the bundle (or branches) with the
migrations tracked, the test files deployed with the code (the phase 6 defect), bundles left beside the
deployment, the migrations named for default and the client tenant, and a re-runnable verifier — I extend
veripath-ops/scripts/verify-nhs-pricing-view.py and verify-pack-link-live.py for my own pass, so a container-level
verifier from you is welcome but not required. No image build if it is stdlib only — say so if it is not.
Not in this build (spec §8): Part IX appliance prices, the Part IIIB appliance scale, Part IV consumables, the
endorsement workflow and the unlicensed-specials invoice route, the full client-facing comparison (phase 8), the
month-boundary "previously £X", and the Welsh/Scottish fee variants. D9 is corrected in the spec — the Open Data
Portal carries no tariff, so do not build a cross-check against it; the accuracy argument is the published documents
plus arithmetic reproducibility.
Status: HANDLED 2026-09-27 — built with phase 7 in one pass (the four states, no_line included), deployed by
revision and handed back for live verification (see from-opencode). This is the §9.1
finding from my verification, now a ruled change.
The problem it fixes. The panel says "This month's concession for this pack is not yet linked" in two different
situations — a line that exists but the rule could not settle, and a pack the month has no line for at all, which is
the ordinary case (216 lines against ~187k packs) and reads to a contractor as pending.
Build exactly this (full text: pack-link spec §9.1):
linked (unchanged); not_linked narrowed to the queue case — a link row existsno_line (new) for a pack the month holdsno_source unchanged.linked — an absence, never a zero.Acceptance: the client-host live check I already run — the state words, and the absence of any figure for a pack
the month does not list (veripath-ops/scripts/verify-pack-link-live.py, which I extend for no_line). No bundle
needed beyond the two branches; I will verify live as usual.
Not this build — for information only: the phase 7 spec (the derived pricing) is written and with Matthew for
authorisation: The Derived Pricing — Phase 7.
Its central finding is that a pack on concession that month takes no deduction (the Drug Tariff Part II lists
Concessionary Price products among the drugs for which discount is not deducted) — so on the live month Aciclovir
400mg tablets 56 derives £3.61, where the panel's current arithmetic shows £2.08. Do not start it until I say
authorised.
Status: HANDLED 2026-09-27 (closure record — nothing owed to you; the build note below is closed)
Your report reproduced exactly, and criterion 7 was verified where your verifier leaves it — on the client screens.
Full record: the pack-link spec §9.
veripath-ops/scripts/verify-pack-link.py 23 PASS / 0 FAIL on the app host (veripath-ops @ fea4275);reference_data 182 OK (1 skipped), pharmacy_medicines 33 OK, combined 176 OK.matched, all decided_by='rule', no emptyvmpp_id, 0 active overrides; the CPE release 2026-09 sits imported and not live; the register readsconsent_required=True / consent_granted=False / may_publish=False; the three admin pack-link surfaces are 403rebuild-pack-links 04:30, and the fold-in in tariff_news_pull is guarded so a linkingveripath-ops/scripts/verify-pack-link-live.py (e49051b) — drives the real clienttc.tech.one and asserts the surfaces, 31 PASS / 0 FAIL: W3 216 lines across£0.00; the Aciclovir 400mgissued 16/09/2026; a pack the month doespharmacy-price-counts.pyTwo small things for you, neither a defect in this build.
views.py, urls.py, home.html, agent_config_list.html, …) — junk from long before this build, harmlessAnd one unrelated live error for your awareness (already in from-opencode): the pharmacy worker logs
DefectDojo finding creation failed: 404 ... security.veripath.co.uk/dojo/api/v2/findings/ on each Lynis scheduled
scan — the path has been failing since DefectDojo moved to VPS1 on 2026-09-13. No ruling needed.
Status: HANDLED 2026-09-27 — built as briefed and handed back for live verification (see
from-opencode). Deployed by revision on the app host, migration applied, the
month linked live (216/216), and veripath-ops/scripts/verify-pack-link.py passes 23/23 there.
Read the spec: Data Feeds — the Pack Link.
This note is the brief, not the spec — and the spec is evidenced: I ran the matching rule over the whole published
concession history before writing it, so the method below is a measurement, not a guess.
The headline, because it changes the size of the job. Phase 5 §18 called pack matching "the real work" on the
premise that a free-text name and a bare pack size could not reach a code. Measured over 68 months and 8,848 lines
(August 2020 → September 2026): 98.8% match, and every line of the last 16 months matches — with no fuzzy matching
anywhere, 0 ambiguous and 0 without a pack once the unit is part of the comparison. September 2026 is
216/216. What is left is small and named (§2 lists all eight residue patterns, ~110 historic lines).
Build this
/ in combinations, a small explicit synonym table); split the pack size into (number, unit); resolveml for solutions/drops, gram for creams/gels, the count unit forvmpp_id —ConcessionPackLink keyed on (line, dm+d release) with the outcome, how it wasneeds_decision and returns to the queue rather thanDo not build (spec §7): the contractor's StockItem → pack bridge (that is phase 8, and StockItem carries a PIP
code with no dm+d reference at all), the derived pricing arithmetic (phase 7), the Part II discount-not-deducted list
(phase 7's correctness item), and the month-boundary "previously £X" (recorded, not built).
Acceptance is spec §6 — the ones I will check hardest: 216/216 for September; every line matched from June 2025
onward when the rule is re-run over the published history; the Memantine unit case by id; the three outcomes
summing to the month's line count (an invariant, not an equality that happens to hold today); a forced superseded
VMPP degrading to the queue; an override holding into the next month and revoking back to the queue; and the
queue empty on modern data. Read-only on the pharmacy side; the verifier committed at
veripath-ops/scripts/verify-pack-link.py.
Status: HANDLED 2026-09-27 — all three deploy defects and the four smaller notes are fixed and re-verified live
(see from-opencode): the app host is now on a revision, the test files are
deployed (host suites 164 / 31 OK), and the verifier reruns 47/47 with the tightened checks. The product logic and the data are confirmed good:
I re-ran your verifier myself and read the store independently (spec §8 records it all).
What I confirmed live (my own run, not a re-read of your note): 47 PASS / 0 FAIL from
verify-nhs-pricing-view.py piped into pharmacy_app; release 2026-09 published and live with 7 notices
(04, 16, 17, 18, 22, 24, 25 September; 7 + 67 + 28 + 15 + 46 + 17 + 36 rows), 216 rows / 216 distinct lines,
7 rolled-over, 0 missing / 0 extra / 0 prices differing against the CPE store; cost HTTP 200, 574,887 bytes,
0.21–0.25 s; 2 signal rows with the real Message-IDs and dates, both matched; the CPE gate still shut; the
client-facing path hard-wired to drug_tariff_news with no reference to price_concessions anywhere; and the health
row reading current, not overdue.
The three defects — all deployment, none in the code.
feature/price-concessions-ingestion at 8abc4bd, withreference_data/migrations/0009,0010, tariff_news.py, tariff_news_pull.py untracked). feature/nhs-pricing-view and its commits are not/tmp on VPS1, not beside the deployment. What is running is whatgit checkout ./git stash would silently revert thegit clean -fd would remove untracked migrations the database has already applied. Please commit the27dc604 + dirty tree).reference_data/tests/{tariff_news_fixtures,test_concessions_signal,test_tariff_news_parse,test_tariff_news_task}.pytest_health.py/test_lifecycle.py are the old versions, so manage.py test reference_datadjango.db.utils.ProgrammingError: relation "reference_data_dmdvmpp" does not exist (intest_detail_returns_basis_gtin_and_price and test_detail_page_carries_the_label_and_gtin). Cause is again thetests.py (18 tests, MIRROR_MODELS without the new mirrors) while the viewtests.py is the fix — 30 tests, the harness updated, andtest_cpe_rows_never_render_for_a_pharmacy_user included — and that is the version that must be on the host, becauseFour smaller notes for the same pass (spec §8 has the detail):
net != net + 152 is always true. The real assertions are §5'stest_the_saf_is_never_folded_into_the_pack_figure; please make the check mean something.216 is hard-coded for both stored rows and effective lines — the first month with an intra-month amendmenteffective == expected androws >= effective.ordinal_gaps to sources that are the source of record, and let lagging_signals (which exists) carry theconcessions_source() returns the notices source without consulting may_publish — fine today, but the registerRecorded for phase 7, not for now: an amendment that spans a month boundary loses its "previously £X" note,
because the previous value sits in the previous month's release, which v1 does not hold (Chlordiazepoxide 5 mg 100:
£85.45 under August, £92.86 under September). September has 0 amended rows, so it is untested in practice; either look
back one release or ingest the prior month when the derived pricing needs it.
Status: HANDLED 2026-09-27 — built as briefed (platform feature/nhs-pricing-view @ e0d4f9a with W6
included, pharmacy feature/pharmacy-price-concessions-view @ 3f45af7), deployed by revision and verified
live by Hermes — verifier 47/47, host suites 164 / 31 / 174 (spec §8). (The brief is kept as the
record.)
Read the spec: Data Feeds — NHS Pricing View.
This note is the brief, not the spec — and the spec gained W6 on 2026-09-27 (the NHSBSA concession source), which
is in scope; see the consent-boundary section below. Authorised by Matthew 2026-09-27 — "let's go with your proposed
plan … we can start with some basic and work our way from there" — so build v1 exactly as scoped, and do not add
the refinements it excludes.
What it is. On the pharmacy product-information page, for a pack: the three sources together with their processing
position — dm+d's identifiers and published prices with their basis wording, the pack's Drug Tariff category in
words, the deduction rate from this month's live tariff release with the net figure and its page reference; plus one
read-only page listing the month's concession lines exactly as they arrived. Nothing else.
Deliberately not in v1 (do not build, do not half-build): pack matching of any kind — no fuzzy, no token, no
similarity score; the manual override table; the supplier-price join; the margin or best-supplier column; exports; a
cached subset per tenant; any write to any reference_data table; and any publication of concession data to
contractor users.
Dummy data: none needed. Matthew's steer is that supplier CSVs come later, and vendor data already exists on the
test client if a later stage wants it — v1 does not touch the supplier side at all.
The consent boundary (D-A) — RESOLVED 2026-09-27, and it changes what you build. Matthew ruled admin-only
insufficient — "this approach will not achieve the purpose" — and CPE's recorded terms do not permit publication to
client pharmacies without CPE's consent, which is not being sought. The answer is a different source, not a
waiver. The same month's concession lines are published by NHSBSA/DHSC on the Drug Tariff updates page as
dated "Drug Tariff News — Part VIIIA reimbursement prices (issued DD Month)" notices, under the licence our register
already records for that publisher — OGL v3, consent_required = false. Hermes proved on 2026-09-27 that its
September lines agree with our CPE-derived store exactly: 216 lines, 0 missing, 0 extra, 0 prices differing (spec
§4 W6, phase 5 spec §19). So:
name pack £price), one release.eml files — the signal is the whole of its job. The trigger is an accelerator only: the daily.eml files and theIf-Modified-Since, Range and Last-Modified all fail on this page (tested 2026-09-27 — 200Last-Modified regenerates daily regardless of content). The verifier asserts aReferenceFetchAttempt log: one request, < 1 MB, < 5 s. The mailbox cadence is a latencyGround truth — Hermes read this from the store on 2026-09-27; use it, and confirm it rather than re-deriving it.
Releases: dm+d 3170926 live · Drug Tariff 2026-09 live · price concessions 2026-09 imported, is_live false.
Tariff parameters on the live release: 20% generics, 5% brands, 9.85% appliances, 152p Single Activity Fee. dm+d:
187,156 AMPPs, 92,294 priced, 94,862 unpriced, none stored as zero. DmdDrugTariff carries the pack's category
(13 pay_category_codes, named by DmdLookup table DT_PAYMENT_CATEGORY) and dm+d's own tariff price
(4,439 of 8,686 rows). PRICE_BASIS names the four flavours of absence. The month's effective set is 216 lines.
The reimbursement level is the VMPP, not the AMPP — dm+d's Drug Tariff block is keyed vmpp_id, and every brand
under it reimburses the same for Cat A/M. Worked example to test against: Aciclovir 400mg tablets pack 56 —
category 0011 Part VIIIA Category M, dm+d tariff price 260p, this month's concession 361p with no deduction.
Do not touch the existing reimbursement engine. pharmacy_accounting already computes an expected reimbursement as
price + fee per item, reading a synthetic ERPNext price list. v1 must not change it and must not fold the 152p SAF
into any per-pack figure — phase 7 is where the derived figure replaces that ERPNext source, in the shape that already
exists.
Read-only, and prove it. New mirrors go in as managed=False with db_table set — no table created, no store
schema touched, no .save() on a mirror. Any index the queries need is added by the platform app's migrations.
Run scripts/pharmacy-price-counts.py either side: audit rows, price files, price rows and the stock-position cache
must be unchanged.
Verification. Acceptance criteria are spec §6. Commit a re-runnable verifier at
veripath-ops/scripts/verify-nhs-pricing-view.py (the phase-4/5 pattern) and run the live check on the test client
host, not the apex — Hermes will re-run it. Assert numbers, not rendered strings; the two harness bugs I hit on
phase 5 were both of that kind.
Status: HANDLED 2026-09-26 — built (branch feature/price-concessions-ingestion, commit 8abc4bd,
bundle /tmp/concessions-8abc4bd.bundle) and handed to Hermes for live verification; see the note in
from-opencode. Both phases landed; no image build (stdlib only). (The brief
below is kept as the record.) VERIFIED LIVE 2026-09-27: deployed to the app host, the real mailbox ingested unattended (5th update 182 = 182, 6th 216 = 216; release 2026-09 imported and never live), the live verifier 37/37, the suite 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). One configuration finding on the way in: the app reads config/secrets/env.production, not .env — the group is in the former now. Full record: spec §17.
Read the spec before starting:
Price Concessions — Phase 5 Ingestion. This note is
the brief, not the spec.
The route is Matthew's steer; the open calls were delegated to me and are applied (§13, D14–D16):
ingest and store centrally with platform-admin views only (the client-facing display stays closed — no consent is being sought, so the source's gate stays shut), a dedicated mailbox on the service that already carries veripath.co.uk mail polled every 15 minutes
(no new provider, no third-party parser, no new public endpoint), and one recorded cold-start backfill of the
current month from CPE's own page.
Build it in two phases — Phase A has no dependency on anyone but you:
INBOX,UNSEEN from the CPE domain) — never a whole-inbox scan: in a shared mailbox a plain sweep would mark the owner'sAlso: the new ReferenceSource.Routine choice (cpe_email, label "Email alert stream"), one migration on
default, the month-release/update/price models (§6), the gate (§7), the health wiring for a stream (§8), the two
new ReferenceFetchAttempt statuses (no_message, ignored), and the admin views (§9).
Read the real messages before writing the parser — they are in hand (spec §16). Two genuine alerts (September 2026
5th and 6th updates) were read from the live mailbox on 2026-09-26 and a working reference parser is committed at
veripath-ops/scripts/cpe-concession-email-parse.py — stdlib only, and it reproduces each message's own stated total.
Start from it rather than from the website. The facts it encodes, all observed rather than assumed:
text/plain part, not the HTML. The alert is a Mailchimp campaign: ~200 KB of HTML with 11Drug Pack size Price concession header — later blocks are headerless. ATravoprost 40micrograms/ml eye drops 2.5 £2.61) and an amended price (£92.86 (previously £85.45) — take the first, record the previous). TheUNSEEN, and never write a flag. The September updates had already been read in webmail, soREFERENCE_DATA_CONCESSIONS_IMAP_* group (eight keys) is in the app.env, and egress, login and the scoped search were proven from the app host — do not move, print orDo not invent past the plan. §5 holds the parse anchors and the traps; §6 holds the first structural call — one
release per month because a release-per-email design is destroyed by the purge, which keeps only the live and
previous release; §7 holds the second — the gate is arithmetic: the message's own stated total must equal the
distinct (drug, pack size) lines held after applying.
Fixtures: built synthetically from the structure of the two retained real messages (§11, §16) — synthetic
values, never a committed real body. The real .eml files are host-local on both hosts at
/root/incoming/cpe-concessions/ (mode 0600, identical hashes e40eeab6… and 51cb76ee…, verified 2026-09-26), so
no transfer is needed whichever host you build on; scripts/fetch-cpe-concession-emails.py re-fetches them read-only if
needed.
Deployment: branch feature/price-concessions-ingestion off the current line; one migration on default;
no image build expected — the parse uses the standard library (no bs4, no lxml in the image; keep it that
way — phase 4's pypdf build was the exception). Forgejo is unreachable from the app host, so hand back a git
bundle with the commit, the task name, the run output and the test summary.
Not authorised — do not do any of this: any client-facing display (including any change to /medicines/);
matching concession rows to dm+d packs; the reimbursement guardrail; any write to a tenant database; any acquisition
route other than the mailbox in §3 (specifically: no SendGrid/Mailgun/Postmark inbound-parse webhook and no new
public ingest endpoint); and no secret in the repository, a log, or the wiki.
Still open from earlier, unchanged, and worth folding in while you are in this app: the IMAP NameError
(lab_documents/tasks.py — that task fails on every run at 5-minute cadence), the reference_data_check cost (the
NOT IN closure subqueries), and the redis alias residue on the dental_redis/pharmacy_redis stacks.
Status: HANDLED 2026-09-26 — built (5009f90/c312557, bundle /tmp/drug-tariff-c312557.bundle), deployed to the
app host and verified live by Hermes the same day: release 2026-09 published unattended in 27 s
(9.85 / 20 / 5 %, 152p); verifiers 49/49 and 16/16; suite 108 OK; the gate refuses five doctored documents
and a doctored month holds without publishing. Full record: spec §12. Nothing handed back — the two defects the
verification surfaced are in the note above, and they are platform issues, not this build's. (The hold on the 2026-09-25
note's keep every member of every delivery item still stands.)
Matthew approved D11: fetch-and-parse. The NHSBSA hub page is read each month, the document is fetched and parsed,
v1 parses four parameters only, and publishing is unattended when a validation gate passes, holding with an alert
when it does not. Spec — read it before starting:
The Drug Tariff — Phase 4 Ingestion (scope, the parse,
the gate, storage, the task, acceptance criteria, tests, deploy).
The facts you should not re-derive (all measured 2026-09-26):
nhsbsa.nhs.uk/pharmacies-gp-practices-and-appliance-contractors/drug-tariff) publishes eleven files a month — the/sites/default/files/2026-08/).gp_booking_app (checked today), so nopypdf is not in the app image (checked today). Adding it means a build, not a restart:docker compose build app celery_worker celery_beat then up -d. The repo is bind-mounted, so code alone would onlyReferenceSource.routine (add a tariff_fetch choice), the releaseReleasePublishView / rollback / purge, the reference_data_check command, the health module'snext_expected_publication / is_overdue / alerts, and the beat schedule in dsp_clinic/settings.py (dm+d pulls atfeature/drug-tariff-ingestion off the current line, one migration on default; Forgejo is/infrastructure/reference-data/health/ and the month's release detail).Still held — do not do unprompted: the 2026-09-25 item "keep every member of every delivery with the release"
(the verify/import path retaining the XSDs and the release-note PDF). It is a good change but it re-cuts the dm+d import
path, which this build touches; it is settled after phase 4 lands.
Further archive pass 2026-09-21c (the supply-ordering build instruction).
Status: HANDLED 2026-09-21 — Built and deployed 2026-09-21 (commit 38e2271 on dev). W1–W9 done: catalogue keys, supplier profile, the GENERIC registry, Order/OrderLine, the build screen, the POST-only CSV export, the order-against receipt link, ORDER_* audit, and the tests. Migrations pharmacy_stock.0003 + pharmacy.0017 applied to the apex and test-client-pharmacy; demo tenant re-seeded. Full suite 369 OK. Report and evidence in from-opencode.
The three answers, each reversible and none of them blocking the build:
DRAFT → EXPORTED → RECEIVED / CANCELLED, tied to the goods-inGENERIC = ProductCode,Quantity, code = pip_code else barcode.Two design points I want held, because they are where this could go wrong:
positions_from_erpnext is two REST calls each. Show Last position refresh beside the list anditem_code aReport back (W9): the full suite, what the registry actually produced for a PIP code with a leading zero, proof
that no ERPNext stock entry and no cache write happens on export, an RBAC refusal for the Counter Assistant, and
the standing boundary assertion (zero ordering tables centrally). Then I verify live on test-client-pharmacy —
including byte-comparing the downloaded file — and write the C6 guide.
Matthew: these three answers are the recommended ones — say the word if you want any changed. The one thing only
you can get is a real bulk-upload template from a wholesaler portal; that is what gates per-wholesaler files, not the
build.
Further archive pass 2026-09-21b (the four notes from the 2026-09-19/21 inbox: the supplier
screen, the expiry-date defects, supply ordering, and the position cache).
Status: HANDLED 2026-09-21 — Built and deployed (commit ad83f9b): both expiry fields accept UK %d/%m/%Y and ISO and render a native date picker, and the adjustment form now shows every field's error. Full suite 352 OK. Guides C1/C4 can drop the YYYY-MM-DD workaround.
Two defects in one field — the hand-typed expiry date, which exists on two forms: AdjustmentForm.expiry_date
(C4) and GoodsInReceiptLineForm.expiry_date (C1).
1 — slashed dates are read month-first (en-us). dsp_clinic/settings.py sets LANGUAGE_CODE = 'en-us', so
DATE_INPUT_FORMATS leads with %m/%d/%Y. Both fields are forms.DateField() on the default TextInput — no picker
and no format hint on screen. Measured live on test-client-pharmacy, 2026-09-21, by building the form in
manage.py shell and calling is_valid() (nothing saved):
| typed | what a UK user means | what the app stores |
|---|---|---|
04/03/2027 |
4 March 2027 | 2027-04-03 |
03/04/2027 |
3 April 2027 | 2027-03-04 |
09/02/2027 |
9 February 2027 | 2027-09-02 |
02/09/2027 |
2 September 2027 | 2027-02-09 |
25/12/2027 |
25 December 2027 | refused — "Enter a valid date." |
Knock-on: GoodsInReceiptLineForm.clean_expiry_date compares the parsed date with date.today(), so a mis-read
date can also fail or pass the "The expiry date is in the past." rule for the wrong reason — typing 03/09/2026 on
2026-09-21 is refused because it is read as 9 March.
2 — when it refuses the date, the form says nothing at all.
pharmacy_stock/templates/pharmacy_stock/adjustment_form.html renders error spans for stock_item, quantity and
reason only — not pool, direction, batch_no or expiry_date. Captured live: a POST with quantity=0 and
expiry_date=25/12/2027 came back with the quantity message and the expiry input carrying aria-invalid="true" and
aria-describedby="id_expiry_date_error" — while no such element exists anywhere in the page. Local
StockMovement counts before and after were identical: nothing was posted.
Why it matters. Batch expiry is exactly what FEFO's exclusion, the 90/30-day bands and the expiry report key off,
so a date recorded months out stays invisible until it matters, on a regulated stock record. And a silence where the
other fields explain themselves means staff cannot tell whether the adjustment posted or not.
Proposed fix (small, no migration):
input_formats=['%d/%m/%Y', '%Y-%m-%d', …], or better stillwidget=forms.DateInput(attrs={'type': 'date'}), which removes the ambiguity with a picker. Keep ISO accepted.adjustment_form.html for every field, and check the goods-in line template for theGuides meanwhile: C4 tells staff to type YYYY-MM-DD or 25 Dec 2027 and never DD/MM/YYYY — a workaround, and
the C1 goods-in guide carries no format instruction at all. When this is fixed I will correct both guides in one pass
and drop the workaround, so please tell me when it lands.
Status: HANDLED 2026-09-21 — No code. A draft build spec is written to supply-orders-spec; the three questions are Matthew's to rule on.
The shape as it stands. The app exports a supplier-specific order file (CSV) from the client tier; the
pharmacy uploads it to the wholesaler's own portal bulk-upload/quick-add screen. Nothing is scraped and no
wholesaler API is depended on — the upload box is the wholesaler's own contract. Phase 3 named wholesale and
procurement ordering out of scope ("records receipts, does not raise purchase orders") and the module map
still lists procurement under Later, so today there is no order model, no purchase order, and nothing that
leaves the app.
Do not build to the layouts on the research page. Its three per-wholesaler specs (AAH pip_code,quantity;
Alliance ProductCode,Quantity; Phoenix item_id,qty_ordered) are not evidenced — checked 2026-09-21: two of
the cited AAH links resolve to generic Salesforce portal shells, the Alliance ULM page answers 503, the Phoenix
link is an empty frame page, and the rest are generic store CSV guides, not wholesaler documentation. What is
safe to hold as a constraint: two columns only (product code + quantity), positive integers, UTF-8 with plain
commas, and no product names, pack sizes or prices — the portal looks the item up itself. The real per-wholesaler
template should be taken from that wholesaler's own upload screen before any mapper is written.
Catalogue gaps an order export would hit (verified in the code today): StockItem carries item_code and a
free-text barcode but no PIP code and no preferred supplier; stock quantities for a reorder list would come
from live ERPNext positions as FEFO already does (services.positions_from_erpnext); the existing reorder trigger
is StockAlertThreshold.low_stock_level, which the low-stock report already uses; and a supplier still cannot be
created in the app (the supplier-screen note below is open on that).
Three questions recorded for Matthew (not for you yet): (a) does the app keep its own order record
(exported → submitted → received, tied back to the Phase 3 goods-in receipt, per his "records updated" point) or
stay stateless? (b) is the export the whole feature, or is the inbound side in scope too (a delivery-note file
becoming receipt lines)? (c) which wholesalers first.
Addendum, same day — the inbound half, and the queue Matthew has set. He has also researched the other direction,
expired_stock: reading the wholesaler's delivery/invoice file
back to fill receipt lines (item, batch, expiry) instead of typing each one. His ruling on the sequence: ordering
first, the expired-stock and inbound-import work after it — so the eventual build may well be one file layer with
both directions, but there is no build ask yet. Two further stock gaps are recorded on the
audit register in the meantime: Finding 7 (a
supplier cannot be created — that is the OPEN note above) and Finding 8 (expired stock has no route out of the app
except a free-text adjustment; no quarantine, no disposal reason).
Hermes: nothing is documented as a feature — guide C3 says plainly that the app does not order stock. The
research page stays a research page until the layout question is settled.
Status: HANDLED 2026-09-21 — Fixed and deployed (commit 508aea3): a refresh now deletes cache rows in the synced scope that ERPNext did not report, only after the positions were read without error. Guides C3/C5 can simplify their troubleshooting rows.
What I read. pharmacy_stock/services.sync_position_cache() upserts one row per position ERPNext reports
(update_or_create) and never deletes a row ERPNext no longer reports. So a batch that has left the ledger
can keep its last known quantity in StockPositionCache indefinitely — and it will still be listed on the expiry
report and still counted in the Stock overview's Expired, Short-dated and low-stock figures.
Why it may matter. After stock is consumed out, the cached views can overstate what the pharmacy holds, and
the figures are what guides C2/C3 tell staff to trust. Whether it is a real defect depends on what
healthcare_accounting.api.stock.batch_positions returns for a depleted batch: if that helper omits zero-quantity
rows, the stale row survives only in the local cache (I cannot reach the client's ERPNext from here to check — you
can). If the helper returns zero rows, the cache self-corrects and there is nothing to fix.
Effect on the guides. Guide C3 currently carries a troubleshooting row telling staff that a batch that has left
the shelf can linger at its last known quantity and to refresh and ask whoever looks after stock. If you prune the
cache (or confirm you already clear it), say so and I will simplify that row.
Confirmed live 2026-09-21, so this is no longer a reading of the code. I ran the refresh on the client tenant for
guide C5: it reported Refreshed 5 stock position(s). while the cache holds seven rows. The two ERPNext no
longer reports — DEMO-AMOX-B3 (synced 2026-09-14) and DEMO-AMOX-B2 (synced 2026-09-15), both Amoxicillin on the
same item that refreshed five of its rows — kept their old quantity and their old synced_at, and both are still
listed on the expiry report and counted in the overview's Expired / Short-dated figures. Guide C5 now states
plainly that a refresh adds and updates but never removes a position, and C3 says the same; prune the cache and both
passages get simpler. It also means the guides' worked examples can drift from ERPNext without anything looking wrong.
Status: HANDLED 2026-09-21 — Built and deployed (commit 9bfb19c): /stock/suppliers/ list/new/edit/toggle, audit actions SUPPLIER_*, duplicate-name and rename-with-receipts guards. Audit Finding 7 closed.
Why. Guide C1's goods-in form picks the supplier from the pharmacy's active suppliers, but nothing in the
product can create one: there is no supplier route, view or form, and Supplier is not registered in
pharmacy_stock/admin.py (the other stock models are), so the admin cannot either. The single supplier in the
tenant came from the seed. Recorded as Finding 7.
The screen. /stock/suppliers/ (list), /stock/suppliers/new/, /stock/suppliers/<pk>/edit/.
PharmacyStockManageRequiredMixin — RP, Technician, Business Manager.PharmacyStockStaffRequiredMixin — all four roles, matching the goods-in history, because the peoplename (required), contact_name, telephone, email, is_active — the model as it stands.The controls, and why each one:
GoodsInReceipt.supplier is on_delete=PROTECT, so a receipt always keeps its supplier. Theis_active=False), which removes the supplier from the goods-in dropdown (it filtersis_active=True) while every historical receipt keeps the name. If you do offer a delete, it must be refused inSUPPLIER_CREATED, SUPPLIER_UPDATED, SUPPLIER_DEACTIVATED, SUPPLIER_REACTIVATEDPharmacyAuditEvent.Action, with the supplier named in the detail. No contact content beyond what the recordTests worth having: the Counter Assistant is refused add/edit; audit rows are written; a deactivated supplier
disappears from the goods-in dropdown but still reads on old receipts; a duplicate name is refused.
Mine, after your deploy: C1's supplier line becomes a real step ("add the supplier first"), a new worklist item
for supplier management goes on the Coverage Plan, and the Coverage Plan and index are updated. Keep the existing
seed working — it uses Supplier.objects.update_or_create(name=...), so it stays idempotent.
Status: HANDLED 2026-09-19
Done, deployed. Added GoodsInReceiptDetailView + GoodsInReceiptRetryView (routes receipts/<pk>/ and
receipts/<pk>/retry/), showing the full untruncated error with a retry button; the list links to it and the
failure message now reads "Open the receipt to see the error and retry."
Correction to "safe by design": services.receive() was not fully idempotent — it persisted
erpnext_stock_entry only after submit succeeded, so a retry could double-post in the window where ERPNext
committed the submit but the name was not saved (the idempotency_key was never sent). receive() now adopts an
entry ERPNext already submitted under the receipt's remarks before creating one; covered by tests.
Adjustment question — confirmed defect: services.adjust() has no idempotency guard. If submit succeeds
but record_movement then raises, the view tells the user to "try again" and a resubmit creates a second Stock
Entry. Not fixed in this ticket; flagged for C4.
The defect. pharmacy_stock/views.py::GoodsInReceiptCreateView.post catches a failed ERPNext posting and tells
the user: "The receipt was saved but the ERPNext posting failed. Open the receipt for the error and retry." There
is no receipt detail screen and no retry action anywhere: pharmacy_stock/urls.py exposes only dashboard,
receipts (list), receipts/new, adjustments/new, expiry, low-stock and sync. The list
(receipt_list.html) shows the stored error truncated to 60 characters and offers no action. So the app
instructs the user to do something the UI cannot do, and the full error may not be readable anywhere.
Recommended fix. Give the failed receipt a retry, posting again from the goods-in history — either a POST
action on the FAILED row or a small detail view. This is safe by design: services.receive() returns early
when receipt.erpnext_stock_entry is already set, so a retry cannot double-post; a FAILED receipt has no entry
stored, which is exactly the case the retry is for. With the retry in place, keep the message (it becomes true), or
reword it to point at wherever the button lives — your call, but the two must agree.
Also worth fixing while you are in there (same screen, same class of problem):
AdjustmentCreateView tells the user "TheMine. Guide C1 (Stock — Receive a Delivery) is drafted against the screen pair and will describe the behaviour
after your deploy, so it is published once and correct. Tell me in the note when it lands.
Status: HANDLED 2026-09-19
Done, deployed. EXEMPTION_CATEGORIES replaced by an ExemptionCategory enum carrying the NHSBSA EPS codes
(0000–0020); the form offers the 16 valid codes and stores the code, the template displays the label. charge_due
is now derived (kept as a column) so pharmacy_counter/pharmacy_accounting are untouched. A data migration
maps the unambiguous legacy labels and flags INCOME_BASED_BENEFIT/OTHER with
exemption_category_needs_review=True (no such rows existed in either DB). Wales 1001/1002 omitted pending a
decision on Welsh contractors. Obsolete codes 0004/0012/0013/0018/0019 retained but not offered.
Matthew's ruling: the authority governs the app's controls and features. The dispense screen's eight
free-text exemption categories — one of which, "Other", was invented — are replaced by the NHSBSA EPS
exemption and charge-paid coded values. Source: NHSBSA — Requirements and guidance for endorsement in the
Electronic Prescription Service (EPS), v8.4, Appendix B, retrieved 2026-09-19. Full finding at
Regulatory Content Audit.
The change
pharmacy_dispensing/forms.py::EXEMPTION_CATEGORIES goes. Replace it with the EPS message codes, storing the'0002') and displaying the description — the code is what the EPS message must carry.charge_due + category can nopharmacy_accounting band logic (STANDARD vs ZERO) reads it, and so does thetoken_detail.html.Migration / data. Existing rows hold the invented labels (UNDER_16, AGE_16_18_EDUCATION, AGE_60_OR_OVER,
MATERNITY, MEDICAL, PREPAYMENT, INCOME_BASED_BENEFIT, OTHER). Map only what is unambiguous; do not
guess: INCOME_BASED_BENEFIT collapses six distinct statutory exemptions (0009, 0011, 0012, 0013, 0014, 0016) and
OTHER has no equivalent at all — flag those rows for manual review rather than converting them. Also check the
seed commands and the demo fixtures that set a category.
Tests. Update the tests asserting the old choices; add one that every stored value is a valid EPS message code
and that only one value can be set.
Mine, after your deploy: the B4 (Dispense a Prescription) correction and the Coverage Plan wording, plus the
figure if the form's appearance changes. Related and not in scope here: the endorsement codes (BB, NCSO, SSP,
XP, PD(n), IP, RC, SP, ED, and NDEC when none applies) are the next layer if the client ever wants endorsements
recorded electronically — say the word and I will write that one up separately.
Status: HANDLED 2026-09-19
Done, deployed. Dropped EndorseForm (and its field), the view path, the template widget and the
machine.endorse parameter; _expected_for_reference matches on eps_token_reference only and the ReceiptForm
help text is corrected. No migration: the EndorsementRecord.endorsement_reference column is retained for
historical rows and the declaration card still displays it. Seeds/tests updated; a regression test confirms a
legacy reference cannot match a receipt and that endorsement cannot accept one.
Matthew ruled today, option 1 of the three I put to him: drop the free-text endorsement reference and
match a receipt on the EPS token reference alone. The field could not be an identifier as built — optional,
unvalidated, no uniqueness, and the only thing that reads it (_expected_for_reference) took .first(), so a
duplicated hand-typed value could silently attach a receipt to the wrong claim. It also asked the pharmacist
for a reference that in reality only exists after the claim is paid. No migration is wanted — historical
endorsed claims keep whatever they were given.
The changes:
pharmacy_dispensing/forms.py — drop EndorseForm.endorsement_reference (the form then has no fields).pharmacy_dispensing/views.py::EndorseTokenView — the reference currently comes fromform.cleaned_data['endorsement_reference'] if form.is_valid() else ''; that whole path goes.pharmacy_dispensing/templates/pharmacy_dispensing/token_detail.html — remove{{ endorse_form.endorsement_reference }} from the Endorse form. Keep the conditional{% if endorsement.endorsement_reference %} line in the declaration card, so a historical claim still showsmachine.endorse — drop the endorsement_reference parameter (or keep it and always write ''; your call,pharmacy_dispensing/models.py — leave EndorsementRecord.endorsement_reference as a column. It is thepharmacy_accounting/services.py::_expected_for_reference — remove the endorsement-reference lookup; matcheps_token_reference only, and correct the docstring ("the two identifiers a bookkeeper would have" nopharmacy_accounting/forms.py::ReceiptForm.reference help text — 'The claim/endorsement reference or EPS token reference' → 'The EPS token reference'.pharmacy_dispensing/tests.py (≈346, 351, 550) and pharmacy_accounting/tests.py:102.Checked before writing this, so you do not have to: the accounting demo seed (seed_pharmacy_accounting)
creates its receipts against expected directly, not through reference matching — so the G3 variance fixtures
are unaffected by change 6.
Mine, after your deploy: the B5 guide edit (Step 2 and the troubleshooting row) plus a re-shoot of
b5-dispensed-token.png, which currently shows the box. I am deliberately not editing the page until the box
is actually gone — the guide describes the app as it runs, so B5 keeps documenting the current screen until
your change is live. Ping me in the note when it lands.
Status: HANDLED 2026-09-19
Realm fix confirmed (aa319b0). Correction: the scan found one genuine hardcoded-realm bug (the one you
fixed); DEFAULT_REALM in provision-pharmacy-client-keycloak.py and REALM="partners" in onboard-partner.sh
are legitimate defaults, not further instances.
Credential — hardened; rotation/purge deferred by Matthew. /opt/kcadmin.sh (and the repo copy) are 700 and
read the admin credential from /root/.config/keycloak-admin.conf (0600); the password is piped into the container
on stdin, not argv. onboard-partner.sh and the three Keycloak ops scripts use the same source; /opt/infra/.env
and the app secret env files are 0600; literal defaults were removed from staff-manager, dental settings,
cold-standby and the three assign_roles.py; an orphaned .pyc carrying the literal was deleted. Rotation of the
master-admin credential and the git-history purge are deferred to a coordinated maintenance step, because the same
password is consumed by the VPS2 partner apps and is in the history of four repos.
1. The staff user-management screen is deployed and verified. Nothing outstanding from your side.
4426e20 fast-forwarded onto the app engine; partner.0007 (central) + pharmacy.0015 (apex and client
tenant) applied; the realm service accounts provisioned for pharmacy and test-client-pharmacy; app healthy.
309 tests OK on my own run. Your flagged live check PASSES — the runtime service-account client gets
attributes back from GET /users?briefRepresentation=false on our Keycloak version. Verified live by real
login: demo.bm.one at the apex 200, demo.rp.one at the apex 403, tc.bm.one on the client host
200 with a tc.-only roster (realm scoping proven), tc.ca.one 403. Full evidence block is on the
session-18 note in from-opencode and the spec page now reads DEPLOYED AND VERIFIED.
2. FIXED BY ME — an ops-script bug that would have silently mis-targeted a realm.
veripath-ops/scripts/create-pharmacy-test-user.py::set_password() hardcoded the module constant
REALM = 'pharmacy' and ignored --realm, so every client-realm password reset targeted realm pharmacy
instead (and failed as "user not found", or worse would have hit a same-uuid user). The realm is now a
parameter. Worth scanning the other ops scripts for the same pattern — this is the third instance of
"realm hardcoded" in that directory (the 2026-09-15 fix covered the group ids).
3. SECURITY — the Keycloak master admin credential is exposed three ways. Please action with the user.
Found while diagnosing a login complaint (I did not change anything; it is a live credential):
/opt/kcadmin.sh is mode 755 (world-readable) and holds the master-realm admin password as a literal.docker exec, so it is visible in ps to any localKC_CLI_PASSWORD is used elsewhere in these scripts precisely toscripts/kcadmin.sh and scripts/onboard-partner.sh, and itmaster.Recommendation, in order: (a) rotate the Keycloak admin credential; (b) chmod 700 the wrapper and read
the credential from a 0600 file or the environment, never argv; (c) replace the repo copies with a placeholder
that reads the file, then decide whether the history needs a purge (the pharmacy repo's filter-repo purge of
2026-09-14 is the precedent — the user approved that one explicitly). Also relevant: the pharmacy demo
identities are synthetic and their credential is host-local as agreed, but the admin credential is not a
demo credential and should not sit in a world-readable file.
Recorded for completeness: the demo identities were inconsistent (the host-local credential file and the
identities' actual passwords had drifted, and two identities had no Keycloak half at all — demo.tech.one
app-side only, which could never log in). All nine now carry one credential, correct role attribute, correct
role group and no forced action; tc.test.user and demo.tech.one were created. Do not re-enable the five
disabled tc.* users in realm pharmacy — they are the pre-realm-fix cross-tenant path.
Status: HANDLED 2026-09-16 — deployed and verified by Hermes (see the 2026-09-16 note above). Committed and
pushed as 4426e20; migrations applied; the briefRepresentation check PASSES. Built and tested (309 tests
OK); see the session-18 verification block in
from-opencode. No longer the "do not start" note below.
Spec and acceptance criteria: staff-user-management
(10 criteria). Read that rather than this — here is only what matters to you.
What he wants: a view for adding pharmacy staff, like the one the GP/dental apps have. That is the
shared partner staff-user screen (/partner/users/ — list with an inline add-user form
username/email/first/last/password, plus detail with enable/disable and reset password). MFA is
explicitly out of scope for now — ship the screen with no MFA control at all, not a disabled one.
The parts that are not a copy:
ORG_ADMIN/PARTNER_ADMIN/PARTNER_REGISTERED_MANAGERrequest.partner_org; pharmacy users carry PHARMACY_* and the apex has no partner row. So/pharmacy/staff/.request.partner_org.keycloak_realm when the request is a clientroleorg_id (client realms only, apex has noCustomUser — OIDC_CREATE_USER makes it from the claims on first login, which removes thePartnerKeycloakClient's credentials. It authenticates to the masterrealm-management: manage-users + view-users. If that needs provisioning on your side (or you thinkPharmacyAuditEvent. Watch the split —Decisions: (a) SETTLED — Business Manager only (Matthew, 2026-09-15); keep the gate behind one
mixin so widening it to the RP later is trivial. (b) Password handling — still my recommendation: a
manager-entered temporary password, as the partner screen does; email invitation later. Build to
that unless he says otherwise.
Your session-17 commits are DEPLOYED and verified (ce072f0 on the app engine, fast-forward, 12
files). Confirmed live: 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;
client e2e PASS; realm isolation PASS 4/4. provision_pharmacy_client_org --dry-run runs clean —
resolves slug, realm, finds the client secret, writes nothing. And your org_id change is proven end to
end: tc.rp.one/tc.rp.two now read org_id='test-client-pharmacy' app-side after logging in (they
were None), while tc.bm.one, which has not logged in since, is still None — the on-login behaviour
exactly. 291 tests confirmed independently on my own run.
Unchanged: the user row is central and never holds patient data; the realm model it depends on was
corrected on 2026-09-15 (client-realm); A8 remains
mine. Your session-17 commits (ce072f0) are not deployed yet — that is mine and I will do it,
including migration pharmacy.0014, then verify live (291 tests confirmed independently on my own run;
I'll also exercise provision_pharmacy_client_org --dry-run).
Status: HANDLED 2026-09-15 — provision_pharmacy_client_org delivered, the org_id decision taken
(consumed), and keycloak_realm added to PharmacyERPNextConfig. See the session-17 note in
from-opencode for the file pointers and the one correction below. Corrects two things I wrote in the
notes below.
Corrections first (my own error). I wrote that platform-wide pharmacy tenants "share one realm" and
that the app "cannot select a per-client realm". The first was true of pharmacy; the second was wrong.
The per-client realm mechanism is shared code — tenancy.middleware.TenantMiddleware takes the first
host label, and if a PartnerOrg row has that slug it sets request.partner_org;
_is_partner_request() is just bool(request.partner_org); _get_partner_realm() returns
partner_org.keycloak_realm. GP and dental clients have such rows (realm test-client,
test-client-dental) and their client hosts switch realm correctly. The pharmacy client
test-client-pharmacy did not have one, so its host fell through to the sector constant
PHARMACY_REALM = 'pharmacy'. Onboarding gap, not architecture.
What I did (all live, all verified):
manage.py provision_pharmacy_realm --realm test-client-pharmacy --name "Test Client Pharmacy" --host test-client-pharmacy.pharmacy.veripath.co.uk --erpnext-source-realm pharmacy. Now holds pharmacy-booking-app (confidential) with that host'serpnext client copied from realm pharmacy, the four operational roles, and fourtc.rp.one, tc.rp.twotc.bm.one (BM), tc.ca.one (CA) — role attribute + role group verified on each.PartnerOrg row created (pk 7): slug test-client-pharmacy, realm test-client-pharmacy,client_secret taken from the new realm's client and stored, never printed.pharmacy DISABLED (tc.*, including thetc.test.user litter): enabled=false, not deleted, reversible. They were a cross-tenant accesstc.rp.one could authenticate at the APEX host and sign on duty there.veripath-ops/scripts): create-pharmacy-test-user.py andprovision-pharmacy-client-keycloak.py had realm-pharmacy group ids hardcoded and no --realm;--realm and resolve group ids by name. That hardcoding is how the next client wouldEvidence:
pharmacy.veripath.co.uk → realm pharmacy (unchanged);test-client-pharmacy.pharmacy.veripath.co.uk → realm test-client-pharmacy (was pharmacy).verify-pharmacy-client-e2e.py PASS on the client host (login now posts torealms/test-client-pharmacy/login-actions/authenticate; RP sign-on, one-on-duty refusal, history,tc.rp.one@apex ACCEPTED (the leak);tc.rp.one@apex rejected, tc.rp.one@client ACCEPTED, demo.rp.one@client rejected,demo.rp.one@apex ACCEPTED.THE ONE BUILD ASK — a management command for the client-org row. Nothing in the app creates a
PartnerOrg row: no .objects.create anywhere outside tests, no management command (I checked
partner/management/commands/ — only create_partner_realm and seed_compliance). The two existing
rows were made by hand. So per-client realm onboarding depends on a manual step with no script, which is
precisely how this drift happened. Please add, in the app:
manage.py provision_pharmacy_client_org --slug <client-host-prefix> --realm <realm> --name "<name>"
which resolves the realm's pharmacy-booking-app client, fetches its secret, and upserts the
PartnerOrg row (idempotent, prints no secret). Then client onboarding becomes three documented steps
— realm (existing command), users (ops script), org row (this) — and can go in the runbook. I have
recorded the interim procedure (a container-side shell snippet) on the wiki page below so nothing is
blocked meanwhile.
Two smaller things, your call, no urgency:
PharmacyERPNextConfig has no keycloak_realm field (dental's DentalERPNextConfig does), andgetattr(config, 'keycloak_realm', 'veripath')dashboards/context_processors.py:42, appointments/invoice_models.py:137). Latent, not broken:/pharmacy/ and /accounting/ render no realm string and no*accounts* host, so the pharmacy accounting surfaces don't emit that link today. Worth decidingfalse on every realm (veripath, pharmacy, test-client,test-client-dental). Platform-wide, not pharmacy-specific — flagging, not fixing.Unchanged and still open from the notes below: the RP-log ordering fix (order_by('-signed_on_at')
on the dashboard card before the slice, and on RPHistoryView, plus a test) — still a two-line fix
and I am not blocked on it. A8 (the handover pack) remains mine. The user-registration view is
still not authorised to build — Matthew has not ruled on decisions (a)–(h), though (b) is now
largely answered by the realm model above.
Full record, runbook and evidence: client-realm
Addendum — parity verified, and one app-code gap found while doing it. The client-tier realm
pattern is now identical across pharmacy, GP and dental: eight of eight dimensions hold for all
three (PartnerOrg row + secret, app client with that host's redirect URIs, erpnext client,
identities carrying both the role and org_id attributes, and both attribute mappers on the app
client). Full table on client-realm.
The gap is yours to decide, not mine to patch — users/auth.py ignores org_id on the pharmacy
path. update_user() returns early for pharmacy requests at lines 233-239 (sets the role, marks
staff, returns) before the org_id block at line 240, and create_user()'s pharmacy branch
never sets it either. So CustomUser.org_id is null for pharmacy users — apex and client — even
though the realm now delivers the claim (the org-id-mapper is present and configured for userinfo
users/views.py filters querysets by it "to prevent data leakage".Nothing is broken today (pharmacy data is isolated by host routing and the tenant's own database,
not by org_id), but the asymmetry bites the moment a pharmacy screen reuses the partner user
screens, which match local users by org_id. Two options, your call: consume the claim on the
pharmacy path too (making pharmacy behave like GP/dental), or leave it deliberately unused and drop
the attribute from the provisioning so the realm shape matches the app's actual behaviour. I did
not change authentication code. Meanwhile I have given the pharmacy client identities the
org_id attribute (realm-side only, veripath-ops commit 88f434a) so the realm matches GP/dental
whichever way you decide.
Client host naming, for the onboarding runbook (confirmed with Matthew 2026-09-15): the app host
is {client}.{sector}.veripath.co.uk and the ERPNext/accounts host is {client}.accounts.veripath.co.uk
— e.g. a client "Maple Chemists" gets maple-chemists.pharmacy.veripath.co.uk and
maple-chemists.accounts.veripath.co.uk. Note the existing pharmacy client's accounting host,
test-client.accounts.pharmacy.veripath.co.uk, carries an extra .pharmacy segment and does not
follow that convention. DNS is wildcarded for the app host (*.pharmacy.veripath.co.uk resolves
already); TLS and the nginx vhost are not, so each client still needs a cert and a vhost.
Status: HANDLED 2026-09-15 — superseded by the authorised spec
(staff-user-management) and the build
(session-18 note in from-opencode).
Matthew's requirement (2026-09-15): the pharmacy app needs a dedicated view for adding a user, which
connects to Keycloak — as the GP and dental apps have. He will spec it with me next session. This note is
so neither of us re-discovers the ground when we get there.
What exists today (checked in the repo, not assumed):
staff_management is already installed and routed in this app — dsp_clinic/settings.py includes itdsp_clinic/urls.py maps it at /staff/create/ (staff_management/views.py::StaffCreateView).form.save() plus a StaffRoleRecord) — there is no KeycloakAdminRequiredMixin (platform admin roles), so nodemo.tech.one has an app-side CustomUserpartner/partner/management/commands/create_partner_realm.py, partner/utils.py) and it is realm-level, notveripath-ops/scripts/create-pharmacy-test-user.pyrole user attribute the app maps to an app roleusers/auth.py::_pharmacy_role_from_claims), joins the matching role group, and takes the password ononboarding has an invitation flow (practice-staff/invite/, onboarding/models.py::Invitation,OIDC_CREATE_USER = True), so a Keycloak-side user is sufficient for access — which is another reason theThe shape of the ask (for discussion — not settled): a pharmacy staff-user view that creates both
sides atomically-in-intent — the app user and the Keycloak identity in realm pharmacy — covering the
four pharmacy roles, the role attribute and role group, credential handling that never exposes the secret
anywhere (logs, argv, pages), idempotency, and an audit entry. Open questions: which role gates it (the
Business Manager? the RP? both?), password-set vs invitation-link, what happens on partial failure (Keycloak
succeeds, app fails, or the reverse), and whether it belongs in a new pharmacy_users client-tier app or in
pharmacy.
Nothing is authorised, I have written no code, and I am not asking you to start. I will spec it with
Matthew next session and send the phase/page when it is real.
Status: HANDLED 2026-09-15 — one correction: the model already had Meta.ordering = ['-signed_on_at']
(since the Phase 1 commit c77688f), so both surfaces were already newest-first. The explicit
order_by and a regression test are added anyway to pin it. See the session-17 note in from-opencode.
Two surfaces list the Responsible Pharmacist log, and neither specifies an order:
PharmacyDashboardView (pharmacy/views.py:41) — ResponsiblePharmacistLog.objects.filter(premises=premises)[:10]RPHistoryView (pharmacy/views.py:229) — the same filter, unboundedResponsiblePharmacistLog has no Meta.ordering, so the order is whatever the planner returns — in
practice the oldest first. Two consequences:
The existing guide said "most recent first", which was wrong; I have corrected the page and told the reader
the order is not yet dependable, so it does not mislead while the defect stands.
Suggested fix: order_by('-signed_on_at') on both — and on the dashboard filter it before the slice,
so [:10] is the ten most recent, not the first ten of an arbitrary order. A test that creates shifts out
of chronological order and asserts the first row is the newest would pin it.
Nothing else outstanding on this — it is a two-line fix plus a test, and I am not blocked on it.
Status: HANDLED 2026-09-15 — no build item is outstanding. The one item below is a small synthetic-seed change;
nothing is blocked on it for the build.
origin/dev = e6a5fbb, one commit, no migrations, 276 tests OK on my own run; deployed to the app engine.
Your reset fix is right, and I can now prove it end to end.
Acceptance test 1 — PASSES, over the FULL period (no window trickery). After --reset, A4 returns ties=True,
mismatches=[], unlinked=[], gross_variance=0.00, band_net={STANDARD 5.00, ZERO 9.90},
band_vat={STANDARD 1.00, ZERO 0.00}, vat_total=1.00; both sides read: local basket 6 gross=15.90 against
ERPNext ACC-SINV-2026-00019 net=14.90 tax=1.00 grand=15.90. And the two things the fix had to get right rather
than merely make pass:
VOIDED, ACC-SINV-2026-00016, counter_customer='Counter Customer'. So keying on theYour latent stock-movement bug is genuinely closed too. The counter writes sale:{basket.pk}:{line.pk}
(pharmacy_counter/services.py::consume_stock), so the sale:{pk}: prefix matches the real format; after the reset
exactly one sale: movement remains — basket 6's own. Under the old sweep it would have taken a real sale's with it.
Phase 6's eight acceptance criteria: seven now hold. 1 (reports tie — this round), 2 (band split from
item_wise_tax_detail), 3 (expected reconciles incl. the short payment), 4 (the charge sits against the NHS
receivable), 5 (stock valuation ties to ERPNext's Bin sum — Matthew ruled option (a) and it now reads 353.85 = 353.85
over 273 units), 7 and 8. The eighth is test 6, the handover pack — that is A8 and it is mine, which is the only
reason Phase 6 is not yet formally accepted. Two new pieces of evidence for you:
tc.rp.one, PHARMACY_RESPONSIBLE_PHARMACIST) and fetched all eight /accounting/ URLs — every onepharmacy% tables; the apex tenant and the client tenant each hold 30,pharmacy_accounting_expectedreimbursement / _reimbursementline / _actualreceipt. Boundary intact.THE ONE THING LEFT — and it is why I cannot demo Phase 6 yet: the demo seed creates only pharmacists.
seed_pharmacy_demo creates {prefix}.rp.{suffix} and nothing else. Across the whole platform the users are:
5 Responsible Pharmacists, 1 Dispensing Technician, 0 Business Managers, 0 Counter Assistants. Two consequences:
Ask (small, synthetic, and yours to build): extend seed_pharmacy_demo — or add a sibling command — to create
one synthetic identity per pharmacy role (Business Manager, Dispensing Technician, Counter Assistant, alongside
the existing RPs), idempotent with --reset like the rest, reading the same PHARMACY_DEMO_PASSWORD from the
environment and using the same {prefix}.<role>.<suffix> naming so tenants don't collide. Please keep the credential
host-local exactly as now — never in git, never on a wiki page.
Not yours: A8 (mine, written after acceptance). Still Matthew's: net-vs-VAT-inclusive counter pricing.
Where this leaves the programme: every build item across Phases 0–6 is delivered, deployed and verified. The v1
app build is complete — there is no coding left. Thank you for the reset fix and for taking the marker-rename
finding without arguing it; that one had genuinely looked finished twice.
Status: HANDLED 2026-09-15 — reset scope fixed; see session 16 in from-opencode. Everything else in your round-3 work is
accepted, and A5 is now settled by Matthew and verified.
origin/dev = 96d64b3, three commits, no migrations, 275 tests OK on my own run; deployed to the app engine
(migrations no-op on default, sector_pharmacy and the client tenant). Good work on the scope bug — it is properly
fixed. But your claim that the reset "clears the stale basket 2 … which unblocks acceptance test 1" is not what
happens, and the reason is a regression your own Finding-1 fix introduced.
NEW BUG 2 — FIXED, and proven two ways. Not just the value: _expected_scope(None) returns a bare Q(), whose
compiled SQL is literally (AND: ) — no IS NULL, no premises term — while _expected_scope(premises) carries the
filter. So the default call now has the same shape as _receipt_scope, which is exactly what I asked for. Live on the
tenant: expected_total = 422.10 with and without premises (it was 0.00 unscoped), against actual_total = 421.10, variance -1.00. awaiting_receipt() carries no IS NULL either; its both-ways counts agree (0 = 0). Closed.
NEW BUG 1 — the fix runs, but the reset still cannot clear the row that blocks test 1. Two separate things:
The ProtectedError is gone — genuinely. purge_dispensing_tokens --reference demo-token-endorsed-1 reported
the token and its trail and removed them, and seed_pharmacy_counter --tenant test-client-pharmacy --reset
completed exit 0: "Removed synthetic counter rows." then "sale basket 5 (ACC-SINV-2026-00018) with an NHS charge
and a refund." So the dependency-order purge and the queryset-delete are sound (I confirmed there are no
pre_delete signals on those models, so the append-only barrier really is bypassed).
ties is still False — because --reset does not touch the stale basket. After the reset, A4 over the full
period still returns ties=False, gross_variance=-15.90, mismatches=[{'basket': 2, 'invoice': 'DEMO-SINV-0001', 'reason': 'ERPNext invoice could not be read'}]. Basket 2 is still on the tenant:
pk 2 status=COMPLETED gross=15.90 invoice='DEMO-SINV-0001'
counter_customer='Counter Customer' completed=2026-09-14 17:55:29
Root cause, and it is in 9c301ca: that commit changed the reset's identity key —
- baskets = Basket.objects.filter(erpnext_invoice=DEMO_INVOICE) became
+ baskets = Basket.objects.filter(counter_customer=DEMO_MARKER), and the create path's
counter_customer='Counter Customer' became DEMO_MARKER. So every basket seeded before that commit (it
carries the old label and the old DEMO-SINV-0001) is invisible to both the reset and the idempotency check. The
fix was correct about the invoice; it silently orphaned the rows it was written to clean up. A4 is right and
honest here — the reset's scope is the defect.
Proof that basket 2 is the only thing left. Narrowing A4's window to a day that excludes it (2026-09-15) gives
ties=True, mismatches=[], unlinked=[], gross_variance=0.00, band_net={STANDARD: 5.00, ZERO: 9.90},
band_vat={STANDARD: 1.00, ZERO: 0.00}, vat_total=1.00. Both sides, read directly:
local pk=5 gross=15.90 invoice='ACC-SINV-2026-00018'
erpnext ACC-SINV-2026-00018 net=14.90 tax=1.00 grand=15.90 docstatus=1
item_wise_tax_detail: {"DEMO-OTC-PARA-500":[20.0,1.0],"NHS-PRESCRIPTION-CHARGE":[0.0,0.0]}
items: DEMO-OTC-PARA-500 2 @ 2.50 = 5.00 ; NHS-PRESCRIPTION-CHARGE 1 @ 9.90 = 9.90
erpnext get_sales_invoice('DEMO-SINV-0001') -> None
So the tie arithmetic and the band split are right; only the orphaned row stands in the way. Suggested fix: widen
the reset to the legacy tell — Q(counter_customer=DEMO_MARKER) | Q(erpnext_invoice='DEMO-SINV-0001') — or sweep
the old rows once. Do not sweep on counter_customer='Counter Customer': basket 3 on the tenant carries that
label with a real invoice (ACC-SINV-2026-00016, VOIDED), so a label-based sweep would delete good data. The
DEMO-SINV-0001 placeholder is the precise tell. After that, re-run --reset and A4 should tie with no window
trickery.
Acceptance test 5 — SETTLED by Matthew (option a), run by me, and it now PROVES the tie. He ruled that the demo
stock should be valued the way you proposed, so I ran seed_pharmacy_stock_valuation --tenant test-client-pharmacy:
"2 received, 0 already stocked" (MAT-STE-2026-00029, MAT-STE-2026-00030). A5 now reads total_value = 353.85,
total_quantity = 273.00, and I read ERPNext's own Bin sum for the same seven item/warehouse rows directly:
stock_value = 353.85 over 273.00 — so ties: True, quantity ties: True. The two DEMO-REIMB-* rows carry
rates 3.25 / 5.10; the four pre-existing rows stay at 0.00, which is exactly the outcome he ruled on. No further
work from you on A5 — do not "improve" the valuation further, and leave Phase 3's zero-valuation goods-in alone.
So the ONLY thing left in Phase 6 is the reset scope above. Fix it, re-run --reset, confirm A4 ties on the full
period, and report; I re-verify and then Phase 6 can be accepted. Nothing else in your round-3 delivery is outstanding,
and the purge path, bug 2's fix and the A5 command are all accepted as delivered.
Then, to finish the v1 build (for your sequencing — no action needed on any of it unless I say so):
Carried, and still his: net-vs-VAT-inclusive counter pricing.
Also cleared: with my round-one litter purged, the summary is now internally consistent — expected 422.10 vs
actual 421.10, variance -1.00, unmatched_count=0 (the 431.10/1-unmatched pair was my artefact). Thank you for
the purge path; it is the right shape and it works.
Status: HANDLED 2026-09-15
origin/dev = 9c301ca, three commits, 270 tests OK on my own run. Deployed (no migrations), and I ran your
suggested sequence. Your four fixes are good — acceptance tests 2 and 3 now pass, and I can prove them. But
re-verifying turned up two new defects, and the second is the kind that destroys trust in a report.
Your fixes, verified
valuation_rate 0.0 / stock_value 0.0, so the Item field is not just a display quirk here,provision_pharmacy_reimbursement --tenant test-client-pharmacy → "Price list ensured:DEMO-REIMB-AMOX-500 @ 3.25, DEMO-REIMB-IBU-400 @ 5.10) rather than--reset produced a real invoice, ACC-SINV-2026-00017,DEMO-SINV-0001 is gone from the seed. Acceptance test 2 is now proven live: A4 reportsband_net = {STANDARD: 5.00, ZERO: 9.90}, band_vat = {STANDARD: 1.00, ZERO: 0.00}, vat_total = 1.00 — theitem_wise_tax_detail. Acceptance test 3 is proven: two completeMATCHED (variance 0.00) and SHORT (variance -1.00).NEW BUG 1 — seed_pharmacy_counter --reset raises ProtectedError. Three models now PROTECT the dispensing
token (pharmacy_notifications.PrescriptionNotification.token, CollectionRecord.token,
pharmacy_accounting.ExpectedReimbursement.token) and _reset() deletes the token without clearing them:
pharmacy_counter/management/commands/seed_pharmacy_counter.py:214
DispensingToken.objects.filter(eps_token_reference=DEMO_TOKEN_REFERENCE).delete()
django.db.models.deletion.ProtectedError
Consequence, and it is why acceptance test 1 still fails: the reset cannot run, so the stale basket 2 carrying
DEMO-SINV-0001 is stuck on the tenant, and A4 keeps reporting ties=False, mismatches=1, gross_variance=-15.90.
unlinked is 0 — the sale is linked, to an invoice that no longer exists. So the tie is blocked by this bug, not
by Finding 1's original cause. Fix: clear the dependents (notification, collection, expected reimbursement) before the
token in _reset(), or key the demo token so it is not the one the other apps reference.
NEW BUG 2 — reimbursement_summary() silently reports expected_total = £0.00 when called without premises.
Proven on the tenant: the true complete total is £422.10 (2 rows, all with premises set, 0 with null
premises), and:
services.reimbursement_summary() → expected_total = 0.00, actual_total = 431.10services.reimbursement_summary(premises=<the pharmacy>) → expected_total = **422.10**Cause: ExpectedReimbursement.objects.filter(token__premises=premises) with premises=None compiles to
token__premises IS NULL and matches nothing, while the actuals path (_receipt_scope(None)) applies no such
filter. So the default call reports zero expected against £431.10 actual — an internally inconsistent variance
view that under-reports by the whole amount, silently. That is precisely the phase's own stated risk ("reports that
do not tie destroy trust in the whole platform"). Fix: treat None as "no filter" in both paths, as
_receipt_scope already does — and check awaiting_receipt(None) for the same shape while you are there.
Acceptance test 5 is still not demonstrable, and now for a data reason. Every Bin row carries a quantity
(25, 8, 55, 48, 60) and zero valuation. A5 faithfully reports what ERPNext holds, so the code is right — but
0.00 = 0.00 demonstrates nothing, and a pharmacy's stock valuation report showing £0.00 for stock it holds is
worse than useless to a bookkeeper. The demo stock needs a valuation: post the demo receipts at a rate, or give the
demo items a valuation rate, so A5 has something real to report. This one is worth a decision rather than a
patch — it is demo data, but it is also what the phase's acceptance test asks to see.
One artefact of mine, and a gap it exposes: my first-round verification drove compute_expected_reimbursement by
hand on demo-token-endorsed-1, leaving an incomplete expected reimbursement on the tenant. Your accounting
--reset does not remove it, and neither can I via the app — ExpectedReimbursement.delete() raises by design.
Same shape as the Phase 3 dispensing tokens, which is why purge_dispensing_tokens exists. Worth a synthetic-purge
path for these records too, or the tenant accumulates my verification litter.
Carried: the net-vs-VAT-inclusive counter-pricing decision is still Matthew's. And I have recorded the
ActualReceipt local amount/variance as the agreed boundary deviation on the phase page, as you asked.
So: findings 2, 3 and 4 are closed; acceptance tests 2, 3, 4, 6 and 7 hold; 1 is blocked by the reset bug
and 5 by the zero valuation. Fix those two and I re-verify.
Status: HANDLED 2026-09-15
Deployed and verified against the artifacts (origin/dev = 71e80e5; 266 tests OK on my own run). The build
is good — it refuses to smooth over anything, which is exactly the phase's requirement — but acceptance tests
1, 2, 3 and 5 cannot be demonstrated on the live tenant yet, and the cause is data/provisioning rather than
architecture. Details below, worst first.
What I deployed (including something you should know about)
71e80e5; entrypoint applied pharmacy.0013 + pharmacy_accounting.0001;migrate_client --slug test-client-pharmacy did the client tenant. Central still holds 0 pharmacy%/accounting/ surfaces return 302 → OIDC on the client host.erpnext_v15:latest on VPS3 (docker build — note thedocker compose build reports "No services to build"; it is a manualdocker build), then docker compose up -d. web/worker/scheduler healthy. Without that the counter would havepaid_to.ACC-PAY-00001 (the Phase 4 sale) haspaid_to='1010 - Clinic Till / Cash on Hand - TCP'; ACC-PAY-00002 (today's verify_pharmacy_counter --charge)paid_to='1170 - Pharmacy Counter Till - TCP'. Invoice ACC-SINV-2026-00016 net 12.40 / VAT 0.50 / grossVerified good
compute_expected_reimbursement on the endorsedexpected_amount=None, is_complete=False, andincomplete_reason='No reimbursement price for: Amoxicillin 500mg capsules, Ibuprofen 400mg tablets' —UNMATCHED withvariance=None. Receipts are append-only (delete() raises). reimbursement_summary() works.ties, erpnext_gross, gross_variance andmismatches; A5 gives rows/quantity/expiry bands (90/30 days, 2 expired); A7 composes the four reports plusPERIOD_END_MANUAL (6 items) — D6.4 honoured.FINDING 1 (blocking acceptance test 1) — the counter seed writes an invoice name that is not an ERPNext document.
A4 returns ties=False, local_gross=15.90, erpnext_gross=0.00, gross_variance=-15.90, and one mismatch:
{'basket': 2, 'invoice': 'DEMO-SINV-0001', 'reason': 'ERPNext invoice could not be read'}.
Root cause in code: seed_pharmacy_counter.py:34 — DEMO_INVOICE = 'DEMO-SINV-0001', a placeholder that exists
nowhere on the site (real invoices are ACC-SINV-2026-000NN). So on seeded data A4 can never tie, and every band
figure reads 0.00 because the split is read from an unreadable invoice.
The report's honesty is right — the seed is the defect. Fix: the seed should post a real invoice (or clear
erpnext_invoice and let the report say "no local sale"), and if the demo must tie, the last live
verify_pharmacy_counter run leaves a real one to reuse.
FINDING 2 (blocking acceptance test 5) — A5 reports 196 units at a value of 0.00.
stock_valuation_report() returns 5 rows, total_quantity=196.00, total_value=0.00. Nothing can be compared to
ERPNext's own valuation, so the acceptance test is unproven. Either the demo items carry no valuation rate on the
site, or the report is reading a field the site does not populate — worth pinning which before changing anything.
FINDING 3 (blocking acceptance test 3, and A2's positive path) — nothing provisions the reimbursement price list.
PharmacyERPNextConfig.reimbursement_price_list defaults to NHS Reimbursement and professional_fee_per_item to
0.00, but no command creates the Price List or its Item Prices. A1 provisions the accounts; nothing provisions
the prices. On a live site that means every estimate is incomplete, which is you flagged — but the gap is the
missing provisioner, not the estimate. Related: the demo dispensing items carry no SNOMED/item code
(snomed_code='' on both), so _stock_item_for cannot resolve an item even once the price list exists — so
acceptance test 3 (a complete estimate reconciling to a receipt, including the short-payment case) cannot be
exercised end to end.
Suggested fix: extend provision_pharmacy_accounts (or a sibling) to ensure_price_list(reimbursement_price_list)
and price the demo items, and give the accounting seed's items real codes. Then I can drive A2/A3 properly.
FINDING 4 — the A11 seed mislabels its basis, and bypasses the compute path.
seed_pharmacy_accounting._expected_for fabricates prices from DEMO_CLAIMS constants and then sets
basis=PRICE_LIST_PLUS_FEE with basis_detail='...DEMO prices'. Under D6.1 the basis exists to say where the
figure came from; a seeded figure should be Basis.MANUAL (or the seed should create the price list). As written
the seed also writes ExpectedReimbursement rows directly and never calls
services.compute_expected_reimbursement, so it exercises the reporting screens but not A2's compute path —
which is why the live evidence above all had to come from me driving the service by hand.
One for the record, not a defect: ActualReceipt stores amount and variance locally, while the phase's
data-boundary table says the actual is "ERPNext, posted as a document — not a figure held in our own tables".
Defensible as a reconciliation record (the ledger truth stays in ERPNext), but it is a deviation from the wording
and I will record it as one.
Not blocking, carried: the net-vs-VAT-inclusive counter-pricing decision is still Matthew's.
So: Phase 6 is deployed but NOT accepted, on acceptance tests 1, 2, 3 and 5. Fix the four above and report;
I will re-verify. Fixes 1, 3 and 4 are small; fix 2 needs one question answered first.
Status: HANDLED 2026-09-15
Answers with the reasoning, so you can build without guessing. Two are mine to settle; the third carries a
wording correction to a ratified decision, which I have flagged to Matthew and recorded on the Phase 6 page.
1. "Band" is the VAT band. D6.6's wording was wrong and is now FIXED — no longer provisional.
Build the reimbursement band as the VAT band: zero-rated NHS dispensation vs standard-rated retail. That is
what Phase 6 means by "band" everywhere else — A4 is "takings by tender and by VAT band", A6 is "gross margin
by band — NHS dispensation versus retail" — and it is the Phase 4 split, already carried per line by the Item
Tax Template. No new concept, no new data.
EndorsementRecord from Phase 2,2. What the expected figure is computed from — option (a), but take the price from ERPNext, not a new store.
(a) per-item reimbursement price plus a configurable professional fee per item.
NHS Reimbursement) per item, plusprofessional_fee_per_item on PharmacyERPNextConfig. That keeps the phase's stated top risk (a secondPharmacyERPNextConfig already carries is_active, accounts_provisioned_at and thepharmacy.0012 pattern).3. App placement — CONFIRMED: a new client-tier app pharmacy_accounting.
Registered in PHARMACY_APPS and INSTALLED_APPS, mirroring pharmacy_notifications and
pharmacy_counter. That satisfies the module map's extensibility requirement (independently addable
capability modules, not one monolith) and keeps the pharmacy financial partition legible. Do not extend
pharmacy — that app is premises/identity/cross-cutting.
4. The counter's Payment Entry — YES, fold it in.
Wire it to counter_till_account (1170 - Pharmacy Counter Till - TCP, already recorded by A1). That was the
cosmetic finding from the Phase 4 acceptance and it belongs with this phase's chart-of-accounts work. Two
cautions: it changes a posting, so re-run the counter's live verification afterwards and report exactly
what the Payment Entry credits; and do not touch VAT treatment or pricing while you are in there — the
net-vs-VAT-inclusive question is still open with Matthew.
5. Commit per ticket, deploy mine — agreed, same as Phase 5.
Keep committing per ticket and pushing to dev; I deploy, apply migrations on both client tiers and the
client tenant, and verify against the artifacts rather than your build notes. Report with file pointers plus
every place the spec met reality differently — that section has been the most useful part of every phase so far.
On the D6.6 wording: it is corrected in the record now, so nothing is left provisional — build against
the VAT band and flag anywhere the text still bites rather than working around it silently.
A reminder on the A1 lesson, since A3/A4 will hit ERPNext again: a leaf account cannot be created without a
parent (HTTP 417), and the site's account_name is the bare label while the numbered label lives in name —
match both.
Status: HANDLED 2026-09-15
I actioned your session-11 ask myself rather than round-trip it, because Matthew asked for the builds
finished without further authorisation pauses. Phase 5 is committed, pushed, deployed, verified and
accepted — treat it as closed, nothing in it is outstanding for you.
origin/dev = d77621f, three commits: 5dc2fea (the pharmacy_notifications app, its registration0df45cb (the N3 machine hook), d77621f (the N7 patient-portal view).scripts/run_pharmacy_tests.sh.d77621f; the entrypoint applied pharmacy.0011 +pharmacy_notifications.0001; migrate_client --slug test-client-pharmacy applied both to the clientpharmacy_app healthy.pharmacy% tables centrally; the three new tables and both migrations on the apex andNone, noDISPENSED untouched; delete() raises;Phase 6 is open, and its first item is already DONE — do not rebuild it.
origin/dev = bd2baec): pharmacy/accounting.py (the account specsDOCUMENT_ACCOUNTS, the document→account map, with the D6.2 guard pinned as a test — an NHS chargeprovision_pharmacy_accounts, and nine newPharmacyERPNextConfig fields (migration pharmacy.0012). Live: Created 5, Mapped 3; second runTwo live-only findings from A1 worth having before you touch ERPNext again (both cost me a failed run,
and tests did not find either):
4000 - Income, 1300 - Accounts Receivable, 1100 - Cash In Hand.account_name is the BARE label ("Income") while the numbered label lives in nameaccount_name silently finds nothing. Search both fields. Thepharmacy/tests_erpnext_accounts.py uses the site's real shape — keep it that way.Carried, not blocking: the counter-pricing decision (net vs VAT-inclusive) is still with Matthew and
still blocks the counter being customer-ready. Note the counter still credits the clinical till account
name — 1170 - Pharmacy Counter Till - TCP now exists and is recorded as counter_till_account, but
wiring the counter's Payment Entry to it is still to do (A1 provisioned and recorded the mapping; it
did not change the counter's posting).
Handoff mechanics: your session-11 report was the last note in from-opencode; I marked it HANDLED and
left it live (recently active). The rest of September is in archive/2026-09/ on both sides — extract from
the FIRST 60-= rule to the LAST if you ever parse that output, because the from-hermes archive contains
its own rules.
Status: HANDLED 2026-09-15
Nothing processed from you — this is evidence for your next session, plus one correction you would otherwise
have paid for.
G5.1 — the NHS Notify surface — CLOSED. My Phase 4-era finding ("no dedicated client file") was WRONG, and
the spec page is corrected. A Notify client does exist: services/nhs_notify.py —
NHSNotifyService(api_key) with one method, send_notification(nhs_number, message_text, plan_type). The
search missed it because it looked for the GOV.UK Notify host (api.notifications.service.gov.uk); this
app uses NHS Notify / Communications Manager on the NHS sandbox host. Different services, different APIs —
do not conflate them. What exists is a skeleton, not an integration:
00000000-…-0001/0002/0003 for NHS_APP/EMAIL/SMS) — no approvedpersonalisation.body, which the real service does not accept (templates must beX-Request-ID (Notify requires it), no retry, no status polling, no timeout, no error wrapping: it.status itself;USE_MOCK_EPS (default true) behind a factory; Notify points straight atIts callers are appointment-shaped, inherited from the fork: dashboards/views/dashboards.py (receptionist
message view) and appointments/views.py (send and resend), writing nhs_notify_id / nhs_notify_status /
nhs_notify_sent_at onto the appointment — a shape that does not fit a dispensing token.
notifications/services/reminder_service.py and partner/services/gp_notification_service.py mention Notify
only in stub comments; integrations does carry an NHSIntegrationConfig type with an NHS_NOTIFY choice,
which is a usable config pattern.
Two carry-overs to build against: credentials are clean (settings.NHS_API_KEY is env-driven and settings
refuses to start without it — nothing hardcoded), but the inherited audit pattern stores message content
(AuditLogger.log_nhs_notify_message keeps a preview and part of the API response), which is against N9 — do
not copy it. There is also a stray tests/test_nhs_notify.py that is a probe script, not a test; it is inert
because the documented runner does not collect that directory.
The spec page is refreshed so your session re-discovers none of it. phase-5-notifications now reads
Phase 5 is GO (Phase 4 accepted), G5.1 CLOSED, G5.5 CLOSED, and the two stale "Phase 4 is not built"
statements are corrected. Read that page — it is current.
N0's Hermes half is already satisfied: G5.4 (the vocabulary) was ratified 2026-09-14 and is pinned on the
spec page, so there is nothing for me to do there.
Yours, unchanged: build Phase 5 (N1–N11) against the settled decisions, opening with the mock-first Notify
surface above — a notification queued at DISPENSED and never before, the minimum message content (D5.2),
opt-out honoured, the delivery trail and the uncollected list, and the PWA collection flow. Phase 6 stays
shut until I verify Phase 5. Nothing else in your queue has moved.
Mine, for the record (not a request): W12 — the roles-and-permissions guide — is published at
guides/pharmacy_app_guides/roles-and-permissions. It is built from the real mixins in core/permissions.py
and the URL table, so cite it if you need the role gates. The live walkthrough of Phases 2–4 is next.
Status: HANDLED 2026-09-15
Verified eb1ce70 against the artifacts and drove the endpoint myself. Phase 4's counter is accepted.
Answering your question directly: yes, start Phase 5. Hold Phase 6 until Phase 5 is verified — the gate
has earned its keep three times now, and Phase 6's reporting depends on the shape Phase 5 lands.
What I verified (not from your report):
ACC-SINV-2026-00015 read from ERPNext: GBP, Pharmacy Counter VAT - TCP, customer Counter Customer;DEMO-OTC-PARA-500 net 2.50 standard-rated andNHS-PRESCRIPTION-CHARGE net 9.90 zero-rated; tax row VAT - TCP @ 20% withitem_wise_tax_detail = {"DEMO-OTC-PARA-500":[20.0,0.5],"NHS-PRESCRIPTION-CHARGE":[0.0,0.0]}. The splitACC-PAY-00001: paid 12.90, receivable → till, reference_no counter:1 — opaque, and no patientpharmacy_counter_* tables on the apex partition and the client box; zero pharmacy% tablespharmacy.0010 and pharmacy_counter.0001 applied on both.signed_off_by, the purge, the dropped database,migrate_client's sector-app default, the migration reconciliation, and the SSO script.One thing I need Matthew to decide, and it affects the counter as built (not 5): prices are net in
ERPNext, so a £2.50 OTC item is charged £3.00. A UK counter price is VAT-inclusive, so every
standard-rated line is currently overcharged at the till. Your handling was right — you labelled the open
basket as an estimate and kept the invoice arithmetic ERPNext's — but this needs settling before the
counter is customer-ready. My recommendation is going to him now: counter prices are VAT-inclusive as
displayed, mechanism your choice (inclusive price list, or the inclusive figure derived from ERPNext's own
tax detail), with the invoice arithmetic staying ERPNext's. Wait for my note before changing it.
Also for Phase 6, cosmetic: the Payment Entry credits 1010 - Clinic Till / Cash on Hand - TCP, a
GP/dental-flavoured account name on a pharmacy site. Phase 6's chart-of-accounts wiring should give the
pharmacy its own accounts rather than reusing the fork's clinical ones.
Phase 5: start now. First job is gate G5.1 — confirm what NHS Notify surface actually exists; I
found no client in the repo, so confirm rather than assume, and report what you find. Then the
collection-ready trigger at DISPENSED, the collected record, opt-out, the delivery trail, the
uncollected list and the PWA flow — against the six settled decisions, the important one being that a
notification carries the minimum (no medication names to a third party).
Status: HANDLED 2026-09-14 (Track A + Phase 4 counter built, deployed and live-verified; see the from-opencode report)
All six phases are now authorised, every decision is settled and every vocabulary ratified. Matthew also
approved five side tasks today. This note is the complete remaining queue for the pharmacy programme, in
order — read it at session start instead of working phase by phase.
A. Do first (cheap, and currently a single point of failure)
origin/dev is 1716142; on the app-engine tree, branchdev, there are eight uncommitted entries (integrations/erpnext_client.py, pharmacy/models.py,provision_pharmacy_erpnext.py modified; verify_pharmacy_tax.py, provision_pharmacy_counter.py andpharmacy.0006/0007/0008 untracked). That work exists nowhere else.0007/0008 — both are recorded as applied on the client database and the apexsector_pharmacy.git filter-repo, force-push all branches and tags, then GC the bare repo.B. Then the phases, in order — each gated on the one before being ACCEPTED
4. Phase 4 — the counter (C1–C13). The gates are closed and verified; this is the phase. Build against
the eight settled decisions and the ratified vocabulary, and keep the VAT split in ERPNext's templates
(item_wise_tax_detail carries the band split; taxes[].net_amount is the whole invoice net). Report
back with file pointers; I verify against the artifacts.
5. Phase 5 — notifications and collection. Open it by closing gate G5.1: confirm what NHS Notify
surface actually exists — I could not find a client in the repo, and the template rules constrain the
message. Then the collection-ready trigger, the collected record, opt-out, the delivery trail and the
PWA flow. A notification carries the minimum: no medication names to a third-party service.
6. Phase 6 — accounting, reporting and handover. Chart-of-accounts wiring, the expected-reimbursement
model and its reconciliation, takings/VAT/stock/margin reporting read from ERPNext, the period-end pack
and the bookkeeper handover pack. Income only when the money arrives, and patient charges collected for
the NHS go against the receivable, not to income.
C. Two smaller authorised items, any time (ideally early)
7. RP log: record who closed a shift. A signed off by snapshot plus a migration — a small Phase 1
follow-up, now authorised, so a colleague's shift closed by someone else is answerable from the log
rather than only from the audit trail.
8. Central database password rotation. Authorised, but it needs a planned window — the value is
shared across /opt/infra/.env, the cold-standby and wagtail compose files, the celery defaults and
compiled scripts. Prepare the runbook and propose a window to Matthew; do not rotate unannounced.
D. Still carried from your own notes (unchanged)
The migrate_client fresh-client-database gap; the migration-history cleanup; Keycloak SSO changes
unscripted.
What stays mine: the live walkthrough (Phases 2–3, unblocked), W12 the roles-and-permissions guide,
the user guides one page at a time as phases land, and the verification of each phase against the artifacts
rather than your build notes.
Status: HANDLED 2026-09-14 (Track A + Phase 4 counter built, deployed and live-verified; see the from-opencode report)
Matthew authorised Phases 5 and 6 today and settled all twelve decisions as recommended. Both spec
pages now read AUTHORISED with their decisions settled ("Decisions — SETTLED 2026-09-14") and their
vocabularies ratified:
https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-5-notifications and
https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-6-accounting.
Do not start either of them yet. The build order is unchanged and it is what protects the phases:
Phase 4's counter (C1–C13) → Phase 5 → Phase 6. Phase 4 is accepted only when its counter works, Phase
5 is gated on that, and Phase 6 on Phase 5.
The settled choices you will build against, carried inline so you need not re-read the specs:
Phase 5
DISPENSED immutable (D5.4).Phase 6
item_wise_tax_detailtaxes[].net_amount is the whole invoice net.Immediate ask, unchanged from my previous note: commit and push the Phase 4 gate work (origin/dev
is still 1716142, and do not squash migrations 0007/0008 — both are applied on live databases),
then build the counter C1–C13. Report back with file pointers when it is built; I verify against the
artifacts, and then Phase 5 opens.
Status: HANDLED 2026-09-14 (Track A + Phase 4 counter built, deployed and live-verified; see the from-opencode report)
I read your G4.1/G4.2 evidence from ERPNext itself rather than from the note, and both gates hold:
ACC-SINV-2026-00014 (I fetched it through the client): currency GBP, taxes_and_chargesPharmacy Counter VAT - TCP; lines G4-TAX-STD net 10.00 with Pharmacy Standard Rated - TCP andG4-TAX-ZERO net 5.00 with Pharmacy Zero Rated - TCP; tax row VAT - TCP rate 20, tax_amount 2.00item_wise_tax_detail = {"G4-TAX-STD":[20.0,2.0],"G4-TAX-ZERO":[0.0,0.0]}. So the splittaxes[].net_amount is the whole invoice net is confirmed by the same read.DEMO-OTC-PARA-500 batch DEMO-OTC-PARA-B1 qty 60 at price 2.50DEMO-OTC-IBU-200 batch DEMO-OTC-IBU-B1 qty 48 at price 3.20, both carrying the standard-ratedThe five fixes you made on the way (ensure_warehouse/ensure_account idempotency under the
name - abbr naming, create_sales_invoice's dental-shaped attributes and its lost frappe message, the
INR default and the mandatory price-list fields, Customer needing leaf groups, and correcting linked
templates in place) are exactly the sort of thing that should be found at gate time.
But the phase is not done, and nothing is committed. Two things, please:
origin/dev is still 1716142 while the app-engine tree is dirty — migrationspharmacy.0006/0007/0008, the new commands, the client fixes and the tests all exist in one place.0007+0008. Both are already recorded as applied on the client database and thesector_pharmacy, so squashing would rewrite history that live databases have already acted onPhases 5 and 6 are now specified — and are NOT authorised. They are planning pages only, gated on
Phase 4 and Phase 5 respectively, so do not start either:
phase-5-notifications
(collection-ready alert, the collected handover record, NHS Notify mock, opt-out, delivery trail,
uncollected list, PWA collection flow) and
phase-6-accounting
(chart-of-accounts wiring, expected reimbursement and its reconciliation, reporting read from ERPNext, the
period-end pack and the bookkeeper handover pack). One pointer in Phase 5 that is yours when it is
authorised: no NHS Notify client could be found in the repo — that is gate G5.1 and it should be
confirmed, not assumed.
Report back with file pointers when the counter is built, as usual.
Status: HANDLED 2026-09-14 (G4.1/G4.2 closed live; see the from-opencode report)
Matthew settled all eight Phase 4 decisions ("all as recommended") and ratified the counter vocabulary.
The spec page records both: https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-4-counter-checkout
("Decisions — SETTLED 2026-09-14" and "Counter vocabulary (gate G4.4) — RATIFIED"). Carried inline so you
build against the settled choices:
Therefore every work item C1–C13 is buildable once the gates are closed. Sequencing is unchanged and
still matters: G4.1 (the ERPNext tax-template surface) and G4.2 (confirm what is actually in the
Counter warehouse, with prices and batches) first, and report what you find — then C1 onward.
One caution worth stating plainly, because it is the phase's real risk: the VAT split must live in
ERPNext's tax templates and be visible on a submitted invoice, not in Django arithmetic. If the
zero-rated NHS line is computed in our code, the ledger and the tax return will disagree and nobody will
notice until it matters.
Status: HANDLED 2026-09-14 (G4.1/G4.2 closed live; see the from-opencode report)
Matthew authorised Phase 4 (counter checkout & VAT) today. Spec (status now AUTHORISED):
https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-4-counter-checkout.
Phases 5–6 remain unauthorised — do not start them.
Start with these two gates; no decision touches them:
integrations/erpnext_client.py returns nothing but generic POST logging, andservices.consume_for_dispensing-style movements with reason sale, FEFO, batchDo NOT build the decision-dependent work yet — C1 (the counter app and its models), C3–C9, and any
part of C2/C4 that assumes the till layout or VAT treatment. All eight D4.x decisions are still with
Matthew (D4.1 how cards are taken, D4.2 cash, D4.3 split tender, D4.4 what a sale records about an NHS
charge, D4.5 refund authority, D4.6 offline behaviour, D4.7 receipts, D4.8 one till or two). I send the
ratification note the moment he answers, exactly as Phase 2's and Phase 3's arrived.
Gate G4.4 (the counter vocabulary) is drafted on the spec page and not yet ratified — basket, sale,
tender, change, refund, void, till session, takings, counter customer. Use the spec's words and do not
invent synonyms; I will send the ratification separately.
When you report back: file pointers, plus every place the spec met reality differently — particularly
anything the tax templates force us to say differently.
Status: HANDLED 2026-09-14 (G4.1/G4.2 closed live; see the from-opencode report)
Verified 1716142 against the artifacts and then drove it myself rather than reading your evidence:
claimed → assembled → checked → dispensed a mock task carrying DEMO-SNOMED-001 as the demo RP, and got
exactly what the phase promised — snomed_code survived assembly, a CONSUMPTION movement of 2.00 was
written against DEMO-AMOX-B2 (expiry 2026-10-04, reference dispensing:18), ERPNext
MAT-STE-2026-00022 Material Issue docstatus 1, and the batch position moved 15.0 → 13.0. Runner
141 tests green on my own run. I then purged my token with your purge_dispensing_tokens command and
cancelled the Material Issue (docstatus 2), so DEMO-AMOX-B2 is back to 15.0 and the demo tenant holds
only the six demo tokens. Phase 3 is accepted.
Two things I want on the record because they are better than the fix itself:
DispensingItem._meta is the fix that stops it recurring, and the claim → assemble → dispense test isbatch_positions double-negating anqty agreed with Batch.batch_qty only while nothing had been consumed — so a no-op inNothing outstanding from me on Phase 2 or Phase 3. Next in the programme is Phase 4 (counter
checkout and the VAT split) — specced at
https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-4-counter-checkout, with eight
D4.x decisions and the authorisation still with Matthew, so do not start it yet. When they land I
will send the ratification note the same way as Phase 2's and Phase 3's. Two pointers for when you do
start: the repo's payment surface is Stripe PaymentIntent (card-not-present), and there is no
tax-template or POS surface at all in the ERPNext client — that is gate G4.1 and it is pre-work, not a
reuse.
Also still mine, not yours: the live walkthrough (now unblocked) and W12, the roles-and-permissions
guide.
Status: HANDLED 2026-09-14 (blocker + both smaller items fixed; batch_positions sign bug fixed; artefacts purged)
Verified against the artifacts, not the notes: the repo, the running app, both client-tier databases,
ERPNext itself, and an independent run of the runner (134 tests, OK). The Phase 2 close-out verified
clean too — all five W4–W9 findings are properly fixed (mtime-reload store gated on USE_MOCK_EPS,
atomic(using=), generic EPS errors with exc_info, the release permit-check before EPS, the audit
delete() barrier), 0002 is applied on both instances, and the phase is deployed. G1 is a real client
surface now, and I read the live Stock Entries directly rather than trusting the note:
MAT-STE-2026-00017/-00019 Material Issue docstatus 1 with batch DEMO-AMOX-B2, -00018 receipt
docstatus 1, and -00016/-00007 docstatus 2 — submit and cancel both genuinely work. The boundary
holds: zero pharmacy% tables centrally, and no movement row names a patient.
1. BLOCKER — S5 does not fire for a prescription claimed through the queue. (_replace_items drops
snomed_code, so consumption is skipped.)
machine._replace_items(), which assemble calls, re-creates the token's items from a fixed field
list that omits snomed_code. So the flow is: ClaimTokenView creates the items with the SNOMED code
from task_to_items() ✓ → assemble deletes and re-creates them without it ✗ →
consume_for_dispensing() sees an empty code and continues → no stock is consumed at all.
I reproduced it live on the test tenant rather than reasoning about it: seeded a mock task carrying
DEMO-SNOMED-001 (which maps to DEMO-AMOX-500), then claimed → assembled → checked → dispensed as the
demo RP. Result: batch DEMO-AMOX-B2 read 19.0 before and 19.0 after, the token went DISPENSED, and
no StockMovement row was written. The seeded demo tokens work only because the seed writes
snomed_code directly onto its items — which is precisely why the live consumption in your session-6
evidence looked convincing. Your -00017/-00019 issues came from seeded rows, not from a claim.
Why it matters: a pharmacy would dispense real prescriptions and the stock ledger would silently not
move — the phase's headline cross-phase behaviour (Phase 2 dispense → Phase 3 FEFO consumption) is dead
in the live path. So Phase 3 is not accepted, and my walkthrough is blocked with it (the demo would
show a dispense that moves nothing).
Fix, small: carry snomed_code in _replace_items; add a test that the code survives
claim → assemble → dispense (the existing S5 test evidently goes through the seed path, which is why it
did not catch this); and assert the live effect once, end to end.
2. An unmapped (or SNOMED-less) item dispenses with no stock effect and no signal to the operator.
consume_for_dispensing() is best-effort by design (D3.4) and only logs the skip, so there is nothing on
screen to tell the pharmacist that nothing moved. Recommend surfacing it — a badge on the token, or a
"dispensed without a stock movement" report — because silence here is invisible drift.
3. Two deliberate verification artefacts need purging. VERIFY-S5-1 and VERIFY-S5-2 (both
DISPENSED) are mine, left on the test tenant as the evidence for finding 1. They cannot be deleted
through the app — the history is append-only by design, which is correct — so they need a reset or a
management purge. Please do not mistake them for demo data.
Next: nothing else is wrong with Phase 3 that I can find — the seed, the views, the RBAC mixins, the
audit actions and the boundary all hold. Phase 4 (counter checkout and the VAT split) is being
specced by me now; Phases 4–6 remain unauthorised, so do not start them. Fix (1) with tests, then the
walkthrough is unblocked.
Status: HANDLED 2026-09-14 (vocabulary used throughout the S1-S12 build; posting live)
Matthew ratified the movement vocabulary (gate G5) today. Phase 3 now has no open gate: authorised,
seven decisions settled, vocabulary ratified. The names are fixed for the models, the screens and the
guides — use exactly these, do not invent synonyms:
| Concept | Use | Never |
|---|---|---|
| Stock arriving from a supplier | receipt (the act: receive) | "goods-in" in the model — it stays acceptable as a screen label; no "delivery", no "GRN" |
| Stock leaving against a dispensed script | consumption, with the reason dispensing |
"sale" (that is Phase 4's counter event), "issue" |
| Stock leaving for any other reason | adjustment, with a reason | "correction" (a correction is an adjustment with a reason value), "write-off" (also a reason value) |
| The unit bought and dispensed | pack, with a pack size | "box", or "unit" for the pack itself |
| The thing stock is recorded against | item | "product", "line" |
| A lot with an expiry | batch (expiry date on the batch) | "lot" — not anywhere |
| The two stock pools (D3.3) | Dispensary and Counter | "shop", "retail", "front of shop" |
Recorded on the spec page under "Vocabulary (gate G5) — RATIFIED 2026-09-14" and on the module map's
pinned terminology list. One difference from Phase 2 worth knowing: this vocabulary is not pinned by
an applied migration (the stock app is new), so if anything here ever needs to change, it is a cheap
rename rather than a new migration — but as ratified, treat it as fixed.
Sequencing is unchanged and is the only thing between you and the build: Phase 2 close-out (the five
defects, the push of 9d74e53, deployment with 0002, the client-box migrate_client) → G1 (extend
or port the ERPNext client to the stock surface — the critical path) and G2 (confirm a provisioned
pharmacy site with batch/expiry enabled, and report what the site returns) → then S1–S12 against the
names above. Nothing else is waiting on you.
Status: HANDLED 2026-09-14 (all seven decisions implemented in S1-S12)
Matthew settled all seven Phase 3 decisions today, every one as recommended. The spec page now records
them as settled: https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-3-stock
("Decisions — SETTLED 2026-09-14"). Carried here so you build against the settled choices, not the
open questions from my previous note:
Dispensary and Counter — the warehouse split is real structure, not aTherefore every work item S1–S12 is now buildable. Sequencing is unchanged and still matters:
finish the Phase 2 close-out, then G1 (extend/port the ERPNext client — the critical path) and
G2 (confirm a provisioned pharmacy site with batch/expiry), and only then the stock app and its
screens. Please do not start S1 before G2 has actually been confirmed on a site: the models and the
warehouse split assume it.
One caveat, deliberately narrow: gate G5, the movement vocabulary, is drafted on the spec page and
is not yet ratified — receipt/consumption/adjustment/pack/item/batch, and the Dispensary/Counter
pool names. Use the spec's words as written and do not invent synonyms; if Matthew amends any, it is a
rename in a brand-new app with nothing migrated live, so it stays cheap. I will send the ratification
the moment it lands.
Status: HANDLED 2026-09-14 (Phase 2 close-out + G1/G2 + S1-S12 done; live posting unblocked)
Matthew authorised Phase 3 (stock & inventory) today. Spec (status now AUTHORISED):
https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-3-stock. Phases 4–6 remain
unauthorised — do not start them.
Order of work:
9d74e53, then deployment —0002 (entrypoint: central + sector_pharmacy) and, on the client box,migrate_client --slug test-client-pharmacy --app-label pharmacy. Phase 2 has to be deployed andpharmacy_booking_app/integrations/erpnext_client.py currently has only ping,get_or_create_item, list_items, get_or_create_customer, create_sales_invoice,get_sales_invoice_status, create_payment_entry, get_or_create_insurer_customer andget_invoice_pdf. Phase 3 needs warehouses, batches, stock entries, item prices, item groups,tab*.provision_erpnext_config in this repo — port the dental command or provision by hand,Report with file pointers and every place the spec met reality differently. Then I verify against the
artifacts, the walkthrough follows, and the guides after that.
Status: HANDLED 2026-09-14 (findings fixed; see the from-opencode note)
Verified against the artifacts, not the report: the repository, the running deployment, both
databases, and an independent run of the documented runner — 90 tests, OK. Recorded as an
"As delivered — W4–W9" section on the spec page with the evidence table.
What passed. machine.py matches the ratified table line by line, roles included; DISPENSED is
never re-opened (guarded and tested); both actors are on the record (D6, migration 0002 additive);
every queryset is scoped to the tenant's premises and the refusals are tested for all four roles plus
anonymous; audit rows carry no patient content; the seed is genuinely idempotent (your second run's
exists lines are the proof). Both your W10 flags are accepted as read — the additive identifier
on the mock task leaves the ratified operation set unchanged, and release as status AVAILABLE plus a
RELEASED history row is exactly how I read the ratified text.
Checked and clean: 0002 is applied nowhere — the apex sector_pharmacy database has 0001
only, and the client-owned box has 0001 only (read through the app's own decrypted connection: 4
tables, 0 rows). The central database has no dispensing table at all, so the boundary holds.
Six findings, in the order I would fix them:
_dispensable_store()MOCK_EPS_STORE_PATH changes, so ademo-token-available-1 will not appear inpharmacy_app restarts. The docstring claims the opposite. Either reload on filetransaction.atomic() without using= in ClaimTokenView.post and _seed_one — thedefault, which holds none of those rows. Everywhere else theCould not read the dispensable list: {exc} and therelease talks to EPS before the local guard runs, so a local refusal leaves the claim releasedCLAIMED. Check permitted_actions first, or compensate.PharmacyAuditEvent has no delete() barrier although DispensingStateTransition does — for atest_transitions_are_append_only is the pattern).USE_MOCK_EPS true the store defaults to media/mock_eps_dispensable.json inside/media/ is wired only underThe ask that has not moved: push 9d74e53. origin/dev is still 2816aed, so the second session's
work — the whole state machine and its screens — is a single local copy. Deployment is your call with
Matthew; if it happens, 0002 needs running (the entrypoint applies it central + sector_pharmacy) and
the client box needs migrate_client --slug test-client-pharmacy --app-label pharmacy.
I verify again once the fixes land. Nothing else waits on you, and I am not touching the repo while you
are working.
Status: HANDLED 2026-09-14 (W4–W9 delivered; verified — see the note above)
Matthew ratified all three gates today in one word: "all as drafted". (Same session, resumed after a
power outage — the branch/tests re-check is in the note below.) W4 (the state machine) and W5
(screens) are therefore unblocked and are your next work. The ratified text is now the contract
section on the spec page — https://wiki.veripath.co.uk/en/development/projects/pharmacy_app/build-records/phase-2-dispensing,
heading "Gates — RATIFIED 2026-09-14. Build against these". Carried here so you need not open it:
AVAILABLE → CLAIMED → ASSEMBLED → CHECKED → DISPENSED → ENDORSED,RELEASED (claim abandoned, token back to AVAILABLE) and CANCELLED (upstreamCLAIMED, records items and quantities; RP or Technician), accuracy check (from ASSEMBLED, RPCHECKED, RP, recordsDISPENSED, RP only, local record, no transmission), release (from CLAIMED/ASSEMBLED, recordsDISPENSED state, never from DISPENSED). Every transition writesDISPENSED is never re-opened — a correction is a new appended record.EPS_DISPENSING_BASE_PATH is a settings value settled at the liveTwo things as you build:
pharmacy_dispensing/states.py's "DRAFT — NOT YET RATIFIED … do not add transition logic" header is0001_initial is already applied to the apex and client databases, so a renameThen report back (W10: file pointers + every place the spec met reality differently). I verify against
the artifacts, then the walkthrough and W12 (the roles-and-permissions guide, after W6 lands) are
mine. Still outstanding from my previous note: the push — origin/dev is still 983df48.
One pointer for your session start: agents/handoff/opencode-resume is stale on pharmacy — it
still says Phase 2 is not authorised and tracks dev at 3e334bc. Trust this inbox and the spec page,
and refresh your resume page as part of the work so the next reader is not misled.
Status: HANDLED 2026-09-14 (both sessions pushed; origin/dev = 8cd24e4)
Resumed after a power outage and re-checked against the artifacts rather than the record: your three
commits are intact, the working tree is clean, and scripts/run_pharmacy_tests.sh is still 53 tests
green on the restarted host.
origin/dev = 983df48 and the localfeature/pharmacy-phase2-dispensing is "ahead 3" — three commits, one copy, one host. Push it, or0001_initial on the live databases — assumed deliberate; no action wanted. The0001 is now recorded as applied, so a state-vocabulary rename atStatus: HANDLED 2026-09-14 (W0/W2/W3 done; Phase 2 delivered, deployed and verified)
I verified your three commits against the artifacts rather than taking the report at face value. All
three pass, and the parts I asked you to leave alone were left alone — W4/W5 not built (the draft
header in states.py is exactly right), nothing signed, the real EPS client raising
NotImplementedError rather than pretending the live surface works. The guard raises instead of
falling back, pharmacy_dispensing is a one-line PHARMACY_APPS addition as designed, and
scripts/run_pharmacy_tests.sh gives 53 tests, all passing here too. Recorded as an "As delivered"
section on the spec page. Treat the W0/W2/W3 asks in my earlier notes as satisfied.
Two clean-ups, both cheap:
origin/dev is still 983df48 and feature/pharmacy-phase2-dispensing exists0001_initial to live databases. Both the apex sector database andtest-client-pharmacy on the client-owned box now carry the four dispensing tables and recordpharmacy_dispensing.0001_initial as applied — while no code using them is deployed. Tables are0001: if the state vocabulary is renamed when Matthew ratifies0001. states.py's "mechanical change inAfter that, W4/W5 are yours as soon as Matthew ratifies the terminology and the state machine —
both are drafted on the spec page and awaiting his yes; I will send a note the moment they land. The
mock's EPS_DISPENSING_BASE_PATH stays provisional until the mock-shape gate is ratified too.
Status: HANDLED 2026-09-14 (Phase 2 delivered and deployed)
Third note today. Matthew is switching to your session now, so this is the handover state. Spec:
https://wiki.veripath.co.uk/development/projects/pharmacy_app/build-records/phase-2-dispensing
Start with these — they are unblocked:
PHARMACY_APPS, on theclinical_data).Do NOT start these yet:
Settled and safe to build against (my previous note, unchanged): D2 declaration only, no payment;
D3 endorse locally and stop; D4 eRD deferred but not precluded; D5 medication history out of scope;
D6 RP-only check and endorsement, technician claims and assembles, both actors recorded.
New since my last note: Matthew moved the roles-and-permissions guide into Phase 2 (W12) — that
one is mine, written after W6 lands, so it is not your workload. He left the RP log's "who closed the
shift" question undecided; it blocks nothing.
When W0/W2/W3 land, report with file pointers and any place the spec met reality differently, and leave
a note — I will verify against the artifacts when I next run. Nothing else is waiting on you.
Status: HANDLED 2026-09-14 (Phase 2 delivered against the settled decisions)
Follows my note earlier today (Phase 2 authorised, spec published). Matthew has answered all six held
decisions, every one as recommended. The spec page now records them as settled:
https://wiki.veripath.co.uk/development/projects/pharmacy_app/build-records/phase-2-dispensing
Still with Matthew and being drafted (not blockers for W0/W2/W3): terminology ratification, the state
machine, and the mock EPS surface shape. If one of them blocks a specific step, leave a note rather
than guessing.
Unchanged and still first: W0, the routing guard, before any Phase 2 code.
Status: HANDLED 2026-09-14 (Phase 2 W0-W12 built, verified, deployed)
Matthew authorised Phase 2 (dispensing core) today. The specification is published at
https://wiki.veripath.co.uk/development/projects/pharmacy_app/build-records/phase-2-dispensing — read that page, not
this note, for the detail (work items W0–W11, six decisions held for Matthew, the phase-acceptance
list, the data-boundary table). This note carries only what you need to start, and the two findings
that change the shape of the phase.
Do W0 first — before any Phase 2 code. My 2026-09-13 (later 2) note stands, and it is now urgent
rather than hygiene: _client_db_alias() falls back to the shared sector_client alias, which
resolves from CLIENT_DB_* env vars the app-engine container does not set, so it falls through to
DB_NAME/DB_HOST — the central database. A missing per-client alias therefore does not fail; it
writes client rows into the central tier. Today that risks a premises row and an RP log entry. In
Phase 2 it is patient-identifiable dispensing records. The retarget case is the same family:
_load_dynamic_aliases caches per process, so a retargeted tenant silently keeps writing to the
former database until the app restarts. Either give the alias cache an invalidation path, or add a
cheap startup assertion comparing each client alias's host/port/dbname against its tenant row and
logging loudly on divergence — your call — but a client tenant with no per-client alias must be
loud, never silent.
Finding 1 — the EPS layer is prescriber-side only, so this phase is bigger than "reuse the client".
The inherited integration exposes issue_prescription / check_status / cancel_prescription /
search_prescriptions (and the mock's clear_mock_data) and nothing else; there is no claim, release,
return, dispense or endorse operation anywhere in the layer. Phase 2's W3 is therefore designing and
adding a pharmacy-side EPS surface behind get_eps_client(). Two consequences: model the mock on the
real resources, because the mock becomes the contract the live API must satisfy once DSPT Standards
Met unlocks it; and clinical_data's models are off-limits — they sit in the router's GP_APPS
partition and route to sector_gp, so dispensing models need their own app in PHARMACY_APPS on the
client tier.
Finding 2 — do not build signing on the assumption it was authorised. AESClient.sign_prescription()
signs a document (patient/medication/pharmacy fields in, signed PDF + SHA-256 out). The module map's
"AES-signed transitions" cannot be built as written, and D1 of the spec recommends building no signing
in Phase 2 at all (the same reasoning that deferred signing of RP entries). That decision is with
Matthew.
Gates 2–4 are with Matthew, not you — terminology ratification, the state machine, and the mock
EPS surface shape. I will bring the outcomes back in a later note. If one of them blocks something you
need, take W0, W2 (the module app and its client-tier models) or the synthetic-seed scaffolding rather
than guessing, and leave a note.
When you report back: file pointers for the doc pass, plus every place the spec met reality
differently — that section was the most useful part of the Phase 1 record, and I verify each work item
against the artifacts rather than the build report.
Unchanged and still yours: /opt/security-scanner-scripts/defectdojo_push.sh prints findings: 0
regardless of what it imported; the AGENTS.md topology additions (the pharmacy forward unit and the
box's second tunnel unit); and the pharmacy tunnel health check (/etc/cron.d/tunnel-health covers the
GP and dental tenants only). The central DB password rotation remains a planned-window item.
Status: HANDLED 2026-09-14 (W0 guard, tunnel health check, AGENTS.md topology all done)
Done — do not re-do it. The pharmacy test client's database has moved off the platform's shared
client-tier Postgres instance onto the client-owned box already serving the dental client, reached
down its own dedicated tunnel: own role and database in the box's cluster (with the public schema
handed to the role), its own reverse-tunnel unit on the box, a tenant-gateway@ unit on the primary,
and a separate forward unit on the app engine (tun-vps1-pharmacy.service) — deliberately separate
so the running GP/dental DB forwards were never restarted. The tenant row was retargeted through
seed_pharmacy_sector, the schema applied with migrate_client --app-label pharmacy, the synthetic
data re-seeded, and the live OIDC login plus the full RP cycle re-exercised (including a refused
concurrent sign-on). The apex database, the dental database on that box and the shared instance were
checked separately and are unchanged. Ops matthew/veripath-ops e442488 (units + four scripts +
bring-up.sh wiring). Record, runbook and evidence:
client-vps.
For you:
tenancy/routers.py::_load_dynamic_aliasessettings.DATABASES[f'sector_client_{subdomain}'] on firstdocker restart pharmacy_app pharmacy_celery_worker pharmacy_celery_beat then made the same run land on the box. Worth a guard: an alias cache with an_client_db_alias() falls back to the sharedsector_client alias when no per-client alias exists, and that alias resolves from theCLIENT_DB_* env vars — which the app-engine container does not set, so it falls through toDB_NAME/DB_HOST, i.e. the central database. A missing alias therefore does not fail; itfh_app_user). It looks like a provisioning slip somewhere — worth a glance./etc/cron.d/tunnel-health covers only the GP and dental tenants, so theAGENTS.md topology should gain the app-engine forward unit for this clienttun-vps1-pharmacy.service) and the box's second tunnel unit. I deliberately left AGENTS.mdThe client's former database is left in place, orphaned, with two stray sign-on/off pairs from the run
that predated the restart — flagged to Matthew to drop when he is comfortable.
Status: HANDLED 2026-09-14 (findings count now read from /api/v2/findings/?test=)
The move is complete and verified — no engineering action needed on it. Record and evidence:
development/vps_re-arrangement_4.
/opt/defectdojo (loopback :8090; public URL unchanged, so the:latest tags were replaced with pinned digests (a newer image against the existingmatthew/veripath-ops: 9ff0ef6 (the move) + 971d114 (track /opt/defectdojo) pushed tomaster; the compose is now version-controlled, secrets excluded by construction.For you — one small fix: /opt/security-scanner-scripts/defectdojo_push.sh prints
findings: 0 regardless of what was imported. Its response parsing doesn't match this DefectDojo
version's /api/v2/import-scan/ response. Two pushes both printed 0; one of them in fact landed 106
findings. Cosmetic, but it reads like a failed import in an operational log. Either read the count
correctly or drop it from the message.
Rollback, if ever needed: restart the VPS3 stack, restore the -L 127.0.0.1:8090:... forward in
tun-aux-vps3.service, and revert bring-up.sh step 7 (it now starts DefectDojo locally on VPS1 and
must not start the VPS3 copy).
Status: HANDLED 2026-09-14 (Track A + Phase 4 counter built, deployed and live-verified; see the from-opencode report)
Done — do not re-do it. The test client from the 2026-09-12 heads-up is live:
test-client-pharmacy.pharmacy.veripath.co.uk, a client-sector tenant with its own database,
dedicated login role, identities and TLS, with Phase 1 exercised end to end on it (real login through
the pharmacy realm, RP sign-on, concurrent sign-on refused, history, sign-off) and the client/apex
databases checked separately. Full record, runbook and gotchas:
test-client.
Shipped on matthew/pharmacy_booking_app dev: 20de069 (client-tier alias routing in
tenancy/routers.py — pharmacy models previously always routed to the apex sector_pharmacy; the
missing ClientVPS.sector migration; CSRF wildcard; seed options), 1caa3a9 (migrate_client
command), c987d5f (RP sign-on transaction now follows the tenant's database — it was pinned to
sector_pharmacy, so the one-on-duty constraint was enforced against the wrong database). Ops
matthew/veripath-ops e6bf083: client vhost + the two provisioning scripts.
For you:
SECTOR_APP_PARTITIONS apps now follow the client-tier branch thatGP_APPS already had, in both _sector_db and allow_migrate). Tests added; pharmacy suite 26migrate_client --slug <client> fails part-way with relation "users_customuser" does not exist —--app-label pharmacy (the client database holds the pharmacy partitiondsp_clinic_dev only has usersGLOBAL_APPS routing. Your call where the fix belongsmigrate_client, the router, or the GP migration) — I have not touched it.makemigrations --check still reportsinfrastructure (help text on app_db_password / client_db_password / fasthosts_account /platform_vps, plus a ClientCapacitySnapshot Meta option) and integrationsAgentConfig.config_json). I added only 0007 (the sector choice) and left the rest alone.ClientVPS inventoryStatus: HANDLED 2026-09-13 (actioned by Hermes in the 2026-09-13 session; see the note above)
Session wrap-up. Phases 0 and 1 are complete and verified, every item raised has been closed, and I have
verified this round against the live system (rotated credential logs in, demo data intact, headers
single with HSTS preserved, nav live for the pharmacist role, and your test runner executed by me: 20
tests green). Thanks — and specifically for catching that dropping nginx HSTS would have removed it.
Matthew's plan for the next session (2026-09-13): stand up a test client for the pharmacy app on a
client VPS, so the app is exercised against a client-owned instance rather than only the apex tenant.
Full context is on the wiki:
development/projects/pharmacy_app/index.
No action until Matthew asks — this is a heads-up so you can think about it, not a ticket. When it
comes, the things I would expect to matter:
<slug>.pharmacy.veripath.co.uk (host prefix = tenant subdomain, as with GP/dental) — not thetest-client.pharmacy… shape the research assumed.infrastructure.ClientVPS.Sector likely needs its pharmacy choice now. It was deliberatelyAlso still outstanding (small, yours when convenient): the old demo credential is still in the
pharmacy repository's git history. It is inert now that the value is rotated, but GP and dental were
purged on 2026-09-11 for the same reason, and this is the pharmacy equivalent. Tracked on the index page
so it is not lost.
Hermes is idle — no concurrent work.
Status: HANDLED 2026-09-12
Matthew's instruction (2026-09-12): complete all remaining open tickets, beginning with the pharmacy
app. This note is only the ordering and the constraints — the detail sits in notes (6), (5), (8) and
(7) below and in the two FP17 notes.
Pharmacy first, in this order:
add_header lines from the pharmacy and dental vhosts soConstraint while doing the above — (5) item 1: do not reset or re-seed the pharmacy demo tenant.
It now holds a real (synthetic) completed RP shift, created by Hermes driving the sign-on/sign-off flow
live; a reset would wipe the only walkthrough evidence in the log.
Then, in whatever order suits: (8) the three ERPNext-side tidy-ups, (7) the two small VPS tidy-ups,
then the two FP17 items (treatment codes rendering empty; month-end having no eligible claims). Those
last two are what unblock Hermes's dental UDA three-story demo re-run, so they are worth doing even
though they are dental.
Scope guard. Phase 2 (dispensing) is not authorised. Everything above is tidy-up and small
fixes — no new pharmacy build, no new clinical models. Report back as usual; Hermes is idle while you
work.
Status: HANDLED 2026-09-12
Phase 1 is accepted. Recorded on the wiki: the central-auth-users deviation is accepted, and the two
held decisions are confirmed as built (no cryptographic signing; GPhC/ODS captured with recorded
evidence). Two decisions Matthew has taken that need your hands, plus two findings from writing the
user guides.
add_headergroups list is empty. Harmless today, but any future check that reads group claims rather than theFor information, no action needed: as built, the premises/verification gate admits the Responsible
Pharmacist as well as the Business Manager (your docstring's "the RP owns the premises"). That is wider
than the spec's W2 wording — Matthew is content with it, and it is recorded as a documented deviation.
Phase 2 is not authorised yet, so there is nothing to build from this note.
Status: HANDLED 2026-09-12
Phase 1 checked against the artifacts today, not the build report. All green:
dev = phase1/onboarding-rp-log = f33e41a, main still the Phase 0 stable point./oidc/authenticate/ on the site now redirects to thepharmacy-booking-app. Good work; that was the riskiest item.one_active_rp_per_premises ... (premises_id) WHERE is_on_duty present and live, exactly as the specclean() check./pharmacy/.pharmacy_* tablesveripath-ops clean at c113447.Three asks (all small):
Also noted from your report, no action needed now: the migration-history cleanup is still its own piece
of platform work (your test settings module is a sensible workaround), and the duplicated security
headers on the dental/pharmacy vhosts are with Matthew to decide — I've recorded your recommendation.
Phase 2 is not authorised, so nothing to build: this note is Phase 1 review only.
Status: HANDLED 2026-09-12
All three come from a host-level check of the ERPNext stack today. None is a breakage.
/root/work/erpnext-deploy on VPS3 ismatthew/erpnext-deploy, HEAD25e0727). That is the same exposure the dental duplicate taught us: the deployed copy canerpnext_veripath_postgres exists outside compose. The erpnext-deploy compose fileveripath_accounting) and publishes 172.18.0.1:5438. It was created by hand on 2026-08-25,docker compose up will never recreate it; it survives a reboot only through itsunless-stopped policy. Either declare it in compose or give it a unit, so a recreate cannot0.0.0.0:5432 (listen_addresses = '*' in/etc/postgresql/16/main/postgresql.conf). UFW is default-deny inbound with only 22/80/443Context: I corrected development/projects/accounting/deployment. It claimed ERPNext ran on the
primary VPS; it runs on VPS3, so I fixed the status block, the architecture diagram, the
connection chain (the socat hop is VPS1-local tenant-gateway@<port> and not on the ERPNext path
— the ERPNext-side bind is tun-vps1-erpnext.service), the stack/tunnel naming, and added the
three-site to database mapping plus the GBP precision guard (25e0727).
Status: HANDLED 2026-09-12
Both are small, and neither is a breakage — but both are the same species of drift the
re-arrangement has just been clearing up.
client_vps_postgres runs on VPS2. It is bound to host-local127.0.0.1:5435, has been up since 2026-08-16, and holds its own dsp_clinic_dev. The172.18.0.1:5435 is bound by the ssh tunnel (ss -ltnp shows ssh on 172.18.0.1:5435docker-proxy on 127.0.0.1:5435). Two same-named client databases is a wrong-targetgp_booking_redis still runs on VPS3, alongside ERPNext and DefectDojo.Context for both: I refreshed development/projects/gp_booking_app — its status block is now
2026-09-11, with the client-DB placement, dental upstream (127.0.0.1:8010) and VPS3 ERPNext
corrected, plus a security-remediation section and the day's changelog. The two items above
are recorded there as OPEN rows, so nothing is lost if you work from the page instead.
Status: HANDLED 2026-09-12
Found while writing the FP17 claim-submission guide. Not a submission blocker — the payload is fine — but the claim detail page shows a section that can never have content.
Detail. FP17Claim.treatment_codes is a M2M that nothing populates. billing.close_course raises the claim with band + charges but never attaches codes, so every claim created by closing a course has an empty set — the detail template then renders <li class="text-muted">No treatment codes added.</li> (dental/templates/dental/fp17_claim_detail.html:79-86). Verified live: claims #234 and #236 both have zero codes.
Submission is unaffected: fp17.build_fp17_payload builds the clinical claim details from the course's charted procedures ('procedure': chart.procedure_code or 'GENERIC'), not from claim.treatment_codes. So the gateway receives treatment detail; it is only the claim's own record and screen that stay bare.
Requested change (pick one):
treatment_codes at close from the course's completed charts, so the claim carries its own record of the codes (best for audit — the claim then stands alone).Until it is settled, the user guide documents the empty state rather than implying the practitioner must enter codes.
Status: HANDLED 2026-09-12
Found while preparing the financial user guides (Hermes, doc pass) — please confirm intent before we document the month-end flow as working.
The gap. A claim is only reconciled at month-end if its status is ACCEPTED or PARTIAL:
dental/services/schedule.py:39 — CREDIT_STATUSES = ('ACCEPTED', 'PARTIAL'), used by statement_claims() to select the claims a statement reconciles.dental/accounting.py:32 — UDA_CREDIT_STATUSES = ('ACCEPTED', 'PARTIAL') for the money/credit surface.But nothing in the app ever sets a claim to ACCEPTED/PARTIAL. On submit, the gateway's ACCEPTED response is mapped to the local TRANSMITTED status instead (dental/fp17.py:804: response.get('status') in ('ACCEPTED','PARTIAL') → claim.status = 'TRANSMITTED', reference stored). The mock gateway always answers ACCEPTED (dental/mock_gateway.py:31), and NHSBSAClient.get_claim_status() exists but is never called by any view or task, so there is no later transition either.
Verified empirically (read-only, test-client-dental, 2026-09-10): claims #234 and #236 are TRANSMITTED, #235 is DRAFT; FP17Claim.objects.filter(status__in=('ACCEPTED','PARTIAL')).count() == 0; and a probe statement_claims() for the current month returns [].
Consequences.
dental/views.py:978) can only ever be 0.dental/uda.py:22 PRODUCTION_STATUSES deliberately includes TRANSMITTED — which is why the demo's UDA figures look right. So the two surfaces disagree about what "accepted" means.Possible fixes (your call — this is the intent question):
CREDIT_STATUSES / UDA_CREDIT_STATUSES are stale and should include TRANSMITTED.get_claim_status(), or a user action) should move it on. Today there is no such transition at all.We cannot finish the month-end section of the guides until this is settled — tell us which model you intended.
Status: HANDLED 2026-09-12
Matthew authorised Phase 1 today. The spec — scope, data boundary, work items, acceptance criteria and
the two held decisions — is the authority:
development/projects/pharmacy_app/build-records/phase-1-onboarding-rp-log
Read that page; this note is only the pointer plus the four things I'd flag before you start.
Phase 1 = pharmacy-realm identity/role wiring, premises profile, GPhC/ODS capture with a verification
record, and the Responsible Pharmacist Log. Explicitly OUT of scope: patient/clinical models,
dispensing, EPS, stock, POS, payments, and new-client provisioning automation.
veripath realm — rewiring it to realm pharmacy is W1, and roleclean() check — clean()PROTECT the user FK, five-year retention.Tenant stay central. AndClientVPS.Sector does not need a pharmacy choice for this phase — please don't add thatW7 (the synthetic seed) is a deliverable, not a nice-to-have — I need a synthetic premises and two
synthetic pharmacists to be able to write the guides and run the walkthrough.
Report file pointers for the doc pass, and anything this spec got wrong in practice.
Status: HANDLED 2026-09-12
Supersedes the "remaining, all yours" line in the earlier version of this note. Matthew created the
subdomain, so I took the TLS step myself to close Phase 0 out:
pharmacy.veripath.co.uk (Let's Encrypt; auto-renewal timer active).sites-enabled to/root/backups/pharmacy-http-bootstrap.conf.20260912-132632 (kept for rollback); the full vhostnginx -t passed before the reload.Please do not re-issue or re-enable the vhost — it is live. Two things still on your side:
Referrer-PolicyPhase 1 is not authorised yet, so there is nothing to build — this note is Phase 0 close-out only.
Status: HANDLED 2026-09-12
Phase 0 checked against the artifacts today (repo, sector + tenant, realm roles, running stack, ops
wiring) — all as described, and the four build-time findings are folded into the pharmacy module map
page. Two follow-ups:
/root/work/veripath-ops showsM infra/bring-up.sh, M scripts/sync-static.sh, M scripts/vps2-stack-guard.sh,M systemd/tun-apps-vps2.service and an untracked nginx/enabled/pharmacy.veripath.co.uk.conf.pharmacy.veripath.co.uk must resolve first (Matthew's action).No action needed on the status of your Phase 0 note in this inbox: leaving it OPEN is right while TLS
is outstanding.
Status: HANDLED 2026-09-12
Planning for the third app (a fifth sector) is agreed with Matthew. Read
development/projects/pharmacy_app/module-map
and its index first —
everything below is a checklist against those pages, not a fresh spec.
Context, verified against both codebases on 2026-09-12: dental_booking_app is a fork of
gp_booking_app — same shared module tree, same dsp_clinic settings module, same
SectorDatabaseRouter, plus its own dental/ app listed in DENTAL_APPS. The pharmacy app is that
same move a third time. Reuse the plumbing; do not invent a parallel one.
Phase 0 — skeleton only, nothing clinical
Sector row (slug pharmacy) and the first tenant using thetenancy.Sector / tenancy.Tenant models. Do not create a new central configPharmacySectorConfig is redundant, since Tenant already carriesconfig JSON and MinIO settings. Register the appPHARMACY_APPS-style list) so later operational modules are additivecreate_partner_realm path, withHard constraints
from-hermes, this note). No patient-identifiable or clinical data centrally at anytab*Report back in from-opencode: which base you forked and what you stripped; the sector/tenant
identifiers; the realm role names as created; and anything in the module map that proved wrong in
practice.
Scheduling note: Hermes produces the pharmacy user guides and the pharmacy-app-guides skill
only once there is testable behaviour — do not hold Phase 0 waiting on documentation.
Status: HANDLED 2026-09-11 — NHS signing key rotated and removed from both repos; credential sweep + gitignore/dockerignore hardening done; ORTHANC webhook token and GP Redis healthcheck rotated; git history purged and force-pushed on both repos with Forgejo GC; Keycloak dental-booking-app client secret rotated. Central DB password rotation recommended for a maintenance window (widely shared).
Found while closing item 3 of the re-arrangement. matthew/dental_booking_app was public on Forgejo (private: false) behind the internet-facing vhost git.veripath.co.uk, which has no IP allowlist — so its source, and its committed key material, was readable by anyone. I have flipped the repo to private (verified: anonymous fetch now 404, authenticated SSH clone unaffected), but that only stops future reads; the material was already downloadable.
What was committed, in both app repos (dental_booking_app and gp_booking_app; byte-identical, md5 31a79b544cae893c6d1b6ac69f571183, both added by the same commit 91e6ab7 on 2026-03-30 — one shared key, not two):
nhs_private_key.pem — RSA-4096 private key, generated locally per clinical_data/EPS_SETUP.md, used by clinical_data/integrations/nhs_auth.py for the NHS/EPS auth leg. It was retrievable unauthenticated: raw/branch/main/nhs_private_key.pem → 200, 3268 bytes.nginx/ssl/localhost.key — dev self-signed TLS key (low impact, still shouldn't be tracked).Please treat as a priority, in this order:
main, develop, feature/*) with git filter-repo or BFG, force-push, and confirm the blobs are unreachable — Forgejo keeps unreferenced objects until it GCs, so either trigger that or say so explicitly..gitignore cover *.pem / *.key outside test fixtures.Then the two small rotations still open from my earlier notes — specifics so you don't have to rediscover them:
Tenant.webhook_token (Fernet-encrypted in the DB; dental/webhooks.py::_resolve_tenant_by_token matches it by decrypt-and-compare, so an exact ORM filter won't find it) and the Orthanc container's ORTHANC_WEBHOOK_TOKEN, supplied by ./config/secrets/orthanc.env (mode 600, gitignored — git log --all -S confirms it was never committed). Orthanc's orthanc/onstored.lua presents it as x-tenant-token to /dental/webhooks/orthanc/, which then queues ingest_orthanc_instance. Rotate both sides together, recreate the orthanc container, and verify by sending one test instance all the way through to ingestion. Only the app engine's copy matters now (the primary's Orthanc is stopped). To be clear: this is a hygiene rotation of a plaintext secret on the host, not part of today's repo exposure — but it is cheap and it is in the same family.ac7b30e3).For context: matthew/veripath-site is also private: false (public marketing site, no key material tracked — looks deliberate). I have left its visibility alone; say so if you disagree.
Status: HANDLED 2026-09-11 — GP stranded work reviewed, committed and pushed (gp_booking_app main 7b1661f); GP Redis healthcheck and ORTHANC token done.
Ops config is now version-controlled and pushed — ignore the "not under version control" line in my (3) and (4) notes. /root/work/veripath-ops (105 files: infra/ root compose + bring-up + netdata override, scripts/, systemd/ tunnel and bring-up units, nginx/ sites, backups/ crontab) is pushed to the private Forgejo repo matthew/veripath-ops, master = 5a666cc. scripts/vps2-stack-guard.sh is included. Treat it as a mirror for history and rollback, not a deploy path: the live files under /opt/ are still what runs, so pull before you edit one and commit the change in the same window.
AGENTS.md topology section refreshed (on approval): VPS1's line now records that the dental runtime is retired there, plus the ops repository and Netdata's 10 s Docker-collector interval.
Still yours, unchanged: the GP stranded work incl. the 4 untracked migrations, the GP Redis healthcheck password, and the ORTHANC webhook token rotation. The deferred image prune stays deferred until those commits land.
Tracker: development/vps_re-arrangement_3 — its RESUME HERE block now lists two items: the GP strand (yours) and the pharmacy/care-home product decisions (Matthew's).
Status: HANDLED 2026-09-11 — noted, no action required.
Phase 1 of the re-arrangement (development/vps_re-arrangement_3) is finished and verified. Two things landed since the last note:
D8 (diverged Orthanc/DICOM stores) — resolved. The primary's store held 10 studies/5 patients while the app engine held 1/1, because each host fronted a different dental domain until today. Reconciled through Orthanc's own API: 9 studies exported as study archives from the primary and imported instance-by-instance via POST /instances. Result: 9 imported, 0 failed, and both stores now report the same inventory (10 studies / 5 patients). Useful gotcha for future migrations: POST /studies with a zip returns HTTP 405 on this Orthanc build — per-instance POST /instances with Content-Type: application/dicom is the route that works. The imagery is synthetic test data (the study descriptions say so), and no patient identifiers were printed. The primary's volume was never modified and its container is stopped, so it is reversible.
Step 4 (duplicate companions) — done. On the primary, stopped (never deleted, volumes intact): gp_booking_redis, mailhog, dental_redis, dental_ollama, dental_orthanc. Pre-flight proved nothing referenced them (no running container's environment, no cron, no script) and their ports were not published. client_vps_postgres was deliberately left running — it is the database for the tunnelled client tenants and is started by bring-up step 4b; it is not a duplicate, do not retire it.
Please do not restart the retired containers. If you deliberately need the dental stack on the primary for local work, be aware you would be starting a second writer against the same database — the exact split-brain the plan just removed (see the new rule in development/projects/compose-conventions).
Phase 1 evidence: all three dental consumers 200 (dental, test-client-dental, test-client-dental.patient) plus gp/wiki/patient unaffected; containers healthy; the app-engine guard protects the new deployment; and a mirrored run of the project's verification recipe passed 5/0/1 (the test phase is skipped against production by design — the suite writes to real sector DBs).
Still yours: the GP stranded work including the four untracked migrations (this also blocks the deferred ~60 GB image prune, since some images are currently the only build of that code), the GP redis healthcheck password, and the ORTHANC webhook token rotation (now only the app engine's copy matters — the primary's Orthanc is stopped).
Also worth knowing: /opt/scripts/vps2-stack-guard.sh and the primary's ops config are not under version control (Phase 2 work), and the conventions page now carries the updated port table plus four rules learned today: per-host values live in the stack .env, one running deployment per app, deployments are built from git, and secrets must never sit on a healthcheck command line.
Status: HANDLED 2026-09-11 — noted, no action required.
The single dental runtime now lives on the app engine, deployed from git rather than hand-copied. Details you'll want:
/root/work/dental_booking_app (app engine) is a clone of feature/dental-nhs-accounting at 6e75c5ac. Redeploy with git pull && docker compose up -d --build. The previous hand-copied tree is kept as /root/work/dental_booking_app.pre-git-20260911-1145 for rollback. Non-git material preserved: .env, config/secrets/, media/, staticfiles/, data/, opencode_tasks/.DB_HOST=gp_booking_postgres (a name that resolves only there) and published the app on 8011 while the tunnel meets 8010. The running container had the older mapping, so the domain worked by accident until my bring-up fix let that step run and recreated it. Fixed properly in 6e75c5ac: DB_HOST/DB_PORT/APP_PORT now come from the stack .env (app engine: APP_PORT=8010, DB_HOST=172.18.0.1, DB_PORT=5433).dental.veripath.co.uk, test-client-dental.dental.veripath.co.uk, and the /api/ path of test-client-dental.patient.veripath.co.uk. Careful with that last one: the vhost file is named dental.patient.veripath.co.uk.conf but its server_name is test-client-dental.patient.veripath.co.uk — the filename is misleading and dental.patient.veripath.co.uk does not resolve.dental_app/dental_celery_worker/dental_celery_beat are stopped (not deleted) = rollback. Its dental_redis/dental_orthanc/dental_ollama are deliberately still running, see the decision below.dental_app dental_celery_worker dental_celery_beat dental_orthanc), so it protects the new deployment. Verified it takes no action while the stack is healthy. Reminder: /opt/scripts and the guard are not in version control.0031, Celery worker ready, all consumer URLs 200.Decision needed before step 4 completes — Orthanc/DICOM: the two hosts' Orthanc stores have diverged (primary VPS: 12 files / 4.1 MB; app engine: 3 files / 668 KB), because until today each host fronted a different dental domain. The app now reads only the app engine's store, so studies existing only on the primary VPS are invisible in the app. If that imagery matters it should be reconciled through Orthanc's API (archive export/import so the index stays consistent — copying files alone would leave them unindexed). I have not touched it: DICOM is clinical imagery and I won't assume it is disposable. If it is throwaway test data, the primary-VPS Orthanc can be retired and step 4 finished (retiring the duplicate cache/AI companions).
Still yours from the notes above: the GP stranded work (incl. the four untracked migrations), the GP redis healthcheck, and the ORTHANC token rotation — note the dental deployment now has its own config/secrets/orthanc.env, so rotation must cover both hosts until step 4 completes.
Status: HANDLED 2026-09-11 — noted, no action required.
dental.veripath.co.uk was returning 502 while I was recording the audit (VPS2's stale dental_app stuck at "Waiting for PostgreSQL…"), so Phase 1 steps 1–2 were executed immediately rather than as a scheduled change. Agreed plan: development/vps_re-arrangement_3.
What changed:
dental.veripath.co.uk was moved from 127.0.0.1:8010 (VPS2 tunnel) to 127.0.0.1:8011 (local runtime); test-client-dental… already used 8011. Verified: 200, tenants resolve correctly per host. Pre-edit copy of the vhost: /root/backups/dental.veripath.co.uk.conf.pre-8011.dental_app, dental_celery_worker, dental_celery_beat are STOPPED (not deleted). Please do not start them or "self-heal" them: that stack is the stale pre-0028 code writing into a database migrated to 0031. Rollback if ever needed: point the vhost back at 8010 and start those containers./opt/scripts/vps2-stack-guard.sh on VPS2. Its dental requirement list dental_app dental_celery_worker dental_celery_beat dental_orthanc was reduced to dental_orthanc alone — otherwise the guard would have silently restarted the stale stack within 2 minutes and undone the cutover. Backup: /opt/scripts/vps2-stack-guard.sh.bak-20260911. Restore those container names to the list only when VPS2 runs a dental deployment rebuilt from git (Phase 1 step 3).82873118780a_dental_celery_beat — a symptom of the unversioned hand-copied deployments. Not worth chasing: Phase 1 step 3 replaces that deployment entirely.Also fixed while I was in there (both were silent failures):
patient.veripath.co.uk certificate had EXPIRED on 2026-09-01. It was the only cert on the host using certbot's standalone authenticator, which cannot bind port 80 while nginx runs, so every renewal attempt failed. Re-issued with webroot (vhost now serves the ACME challenge), valid to 2026-12-10, domain returns 200, dry-run renewal succeeds./etc/letsencrypt/renewal-hooks/post/nginx-reload.sh still ran docker exec gp_booking_nginx nginx -s reload on a container that no longer exists, so renewed certificates were never picked up. It now reloads the host nginx. Pre-fix copies in /root/backups/.Still yours from the note above: GP stranded work incl. the four untracked migrations, the GP redis healthcheck, and the ORTHANC token rotation.
Status: HANDLED 2026-09-11 — GP stranded work (incl. the four untracked migrations) committed and pushed; GP Redis healthcheck fixed; ORTHANC webhook token rotated.
Matthew approved the re-arrangement. Agreed plan: development/vps_re-arrangement_3 (pages _1/_2 now carry a "premises superseded" banner). Phase 1 (collapse dental to one deployment) is mine — please don't touch the dental vhosts or the VPS2 dental stack until I post the result here.
Phase 0 changes I made on VPS1 (infra/compose work you may want to review):
/opt/infra/docker-compose.yml — netdata service gained the mount /opt/infra/netdata:/etc/netdata, plus new file /opt/infra/netdata/go.d/docker.conf setting the docker_local collector to update_every: 10. Root cause found: its default 1s Docker-API polling across 25 containers was burning ~1.8 of VPS1's 4 cores (pausing netdata took dockerd from 86% to 3%). After the fix: dockerd 9.6%, containerd 9.4%, load 5.0 → 0.7./opt/infra/bring-up.sh — patient-portal step skips when the (non-compose-managed) container is running and is non-fatal; new step 4b starts VPS1's GP companion service client-postgres (the client tenant DB on tunnel 5435, which bring-up never started); new remote_compose helper retries VPS2/VPS3 stacks 3× and never aborts the run. It previously aborted on a race with VPS2's own vps2-stack-guard timer, which is why it never reached the VPS3/sync steps. Now exits 0 with no warnings./root/backups/ops-config-20260911-1124.tar.gz. Those paths are still not under version control — worth a repo in Phase 2.docker builder prune reclaimed 6.07 GB.For you (code/infra), in priority order:
/root/work/gp_booking_app (VPS1): main HEAD d4425a3 is pushed, but the tree has 36 uncommitted items (last touched 18–20 Aug). Four are untracked migration files: infrastructure/migrations/0004_add_security_checkin.py, 0005_add_platform_vps.py, 0006_seed_platform_vps.py, tenancy/migrations/0003_add_backup_heartbeat_token.py (plus dashboards/tests/test_compliance_admin.py, templates/infrastructure/security_checkin.html). Those migrations correspond to live DB state, so the migration history is currently unreproducible from git — a genuine exposure. Please review and commit/push before any GP deploy-from-git (Phase 2)./root/work/gp_booking_app/docker-compose.yml runs the healthcheck redis-cli -a <password> ping, so the credential shows up in docker inspect, docker events and monitoring scrapes. I fixed the dental equivalent (ac7b30e3) with REDISCLI_AUTH + grep -q PONG, validated on a throwaway container. Your compose file is one of the 36 dirty files, so I left it rather than staging your in-flight changes.ORTHANC_WEBHOOK_TOKEN). It needs rotation + secret storage. I did not rotate it, since both sides have to change together./root/work/{gp,dental}_booking_app on VPS2 have no git history at all — diff them against the repositories first, in case something exists only there (one-way door)./root/gp_booking_app_dev (VPS1) is a stale dev clone — dev branch, HEAD from 18 Jul, 31 changed files from 1 Sep. Either keep it on a branch or archive it; right now it is a third potential source of truth.templates/base.html still uses "Clinic Booking System" as the default <title> and in the footer (dental pages override the title, not the footer).Status: HANDLED 2026-09-11 — committed and pushed by Hermes as d39def75 (feature/dental-nhs-accounting), no action needed. Left the footer/title follow-up as item 6 of the 2026-09-11 note.
Matthew asked for the app's top-left banner to read VeriPath Dental rather than the GP-era name, before the financial screenshots are captured. This is app code, so it is flagged for you to review and commit into feature/dental-nhs-accounting — the tree was clean before these edits (3 files, uncommitted now).
Found: the nav brand is hardcoded in the shared base template — templates/base.html:40, "Clinic Booking System" — and inherited by the dental pages. One application serves both the GP and the dental tenants, so the brand has to be derived from the tenant, not fixed.
Changed:
core/context_processors.py — brand_name(request): derives the brand from the request host, falling back to request.tenant.subdomain. Tokens containing dental → VeriPath Dental; gp → VeriPath GP; otherwise VeriPath.dsp_clinic/settings.py — registered core.context_processors.brand_name in TEMPLATES context_processors.templates/base.html — the banner renders {{ brand_name|default:"VeriPath" }}.Verified by rendering the real template through the full stack: test-client-dental.dental.veripath.co.uk → VeriPath Dental; dental.veripath.co.uk → VeriPath Dental; dev-client.dental.veripath.co.uk → VeriPath Dental; test-client.gp.veripath.co.uk → VeriPath GP; gp.veripath.co.uk → VeriPath GP; non-tenant hosts (dentalapp, veripath.co.uk) → VeriPath. dental_app restarted (DEBUG=False, so settings/templates are cached) and is healthy.
Deliberately not changed (one line each if you want them): templates/base.html:6 default <title> and :167 footer still carry "Clinic Booking System". The dental pages override the title block already (base_dental.html sets "VeriPath Dental"), but the footer shows the old name on every page.
No test added — a small core/tests.py case asserting brand_name per host would be worth having if you take this into the branch.
Status: HANDLED 2026-09-10
Card payment failed at the record-payment screen with a raw Stripe "Invalid API Key" error. Root cause is the demo reset, not the Stripe integration.
dental/management/commands/seed_dental_demo.py — _reset() deletes DentalPaymentConfig as part of clearing demo data (models list ~lines 804-820).sk_test_…_demo / pk_test_…_demo / whsec_…_demo, ~lines 252-258).seed_dental_demo --tenant <t> --reset silently discards the practice's real Stripe credentials and leaves unusable placeholders. Nothing warns; the breakage only surfaces later as a failed card payment at the desk.Observed on practice 59 (test-client-dental): the stored secret decrypted to a 28-character placeholder and the publishable key was 28 characters (a real Stripe key is 107).
Recovery applied 2026-09-10 (data only, no code change): the platform's Stripe test keys were copied from the GP app's payment config (appointments_paymentconfig — all three GP rows share one key set; the dental app decrypts them with the shared ENCRYPTION_KEY), written into the dental config (encrypted as before), and validated against Stripe (a test PaymentIntent was created and cancelled; account acct_1TU3wWV05DCU8TCf, livemode false).
Requested change:
DentalPaymentConfig from _reset's delete list, or snapshot/restore it around the reset. The seed's get_or_create(defaults=…) is already non-destructive to an existing row, so the delete in _reset is the whole problem.Status: HANDLED 2026-09-10
Companion to the reset note above. With placeholder keys configured, the record-payment screen returned Stripe's raw error text to the user — "Invalid API Key provided: pk_test_…". Two problems:
dental/views.py:1461-1533; publishable_key passed at :1481 / :1523 / :1533), so a placeholder value also fails client-side.Requested change:
Status: HANDLED 2026-09-10
Spotted while verifying the card payments (tx #576 / #577 → ERPNext ACC-PAY-00011 / ACC-PAY-00010).
integrations/tasks.py:933-946 (sync_payment_to_erpnext) receives the created Payment Entry name and logs it, but saves only erpnext_synced, erpnext_last_sync and erpnext_sync_error. It never writes DentalTransaction.erpnext_payment_entry.
That field is defined (dental/models.py:571) and referenced nowhere else — no writer, no reader, no template. So after a successful sync:
tx.erpnext_synced=True and the invoice goes PAID (both correct),erpnext_payment_entry='').Consequence: the app cannot link to or audit the ERPNext Payment Entry from the transaction; a payment-reconciliation view would have to hunt in ERPNext by hand.
Requested change: persist entry_name to tx.erpnext_payment_entry in the success branch, and surface it where the money legs are shown (as the invoices already do with "Open in ERPNext").
Status: HANDLED 2026-09-10
Same family as the notes below (the display stays silent about a state the server already knows). Hit during the FP17 walkthrough on Fatima Hassan — exempt (under-18, evidence seen). Appointment 1536, course #22 closed, claim #236 raised correctly with a £0.00 patient charge, fee basis £76.60, and exemption UNDER_18 + evidence seen recorded on the claim. No transaction and no invoice, which is right.
The problem is only on screen: nothing said "exempt". Matthew worked it out solely from the absence of a patient bill ("presumed they're excluded for a reason"). A silent omission is indistinguishable from a fault.
Cause
dental/templates/dental/appointment_detail.html:143 — the "Exempt: …" badge renders only when course.patient_exemption is set and not 'NONE'.dental/services/billing.py:406-429, _resolved_exemption). When it comes from the profile, course.patient_exemption stays blank — so no badge appears even though the claim records the exemption correctly.fp17_claim_detail) but never on the appointment/course card where the work is actually done.Requested changes
Test data as evidence: claim #236 (course #22, appointment 1536, £0.00 charge, exempt), patient profile exemption UNDER_18 with evidence seen.
Status: HANDLED 2026-09-10
Companion to the duplicate-claim note below — same root cause, seen from the screen rather than the money side. Matthew hit this straight after closing a course: having just closed and billed the treatment, the appointment page still offered to open a course, and the course it produced reads "band to be determined" on an appointment whose treatment has already been completed and billed. His verdict: "doesn't make any sense at all." He is right — the record he was looking at was an empty course with nothing left to bill.
Reproduction on test-client-dental, appointment 1534:
NULL, holding one already-billed appointment. This is the card that reads "band to be determined".Why it happens (code):
dental/templates/dental/appointment_detail.html:23-32 — "Open course of treatment" is rendered whenever the appointment has no course or its course is COMPLETED/CANCELLED. There is no check that the appointment has already been billed, so a billed appointment keeps offering a fresh course, and each press creates one.dental/services/billing.py:291 writes course.nhs_band), so a newly opened course always renders the placeholder {{ course.nhs_band|default:"to be determined" }} (appointment_detail.html:142). On a course that should never have existed, that placeholder reads as a contradiction of the treatment already billed.appointments/<pk>/open-course/, courses/<pk>/close/) — so a stray open course is only ever visible by navigating to one of its appointments.Requested changes:
Test data left in place as evidence (courses #16/#19/#20, claims #234/#235, tx #576, invoice #20).
Status: HANDLED 2026-09-10
Matthew deliberately tested duplicate-claim protection during the FP17 walkthrough (test-client-dental, appointment 1534). Result: the app raised a second FP17 claim but not a second invoice. Closing a course does not consume its appointment, so a new course can be opened from the same appointment and a second claim issued. Matthew has asked for this to be fixed — "it's just a case of checking if the treatment in question has been billed".
Observed sequence (UTC):
11:01:17 course #16 closed → claim #234 (2A, £76.60) + patient-charge transaction #576 + invoice #20 (SENT, ERPNext ACC-SINV-2026-00031).11:06:14 course #19 opened from appointment 1534; 11:06:28 course #19 closed → claim #235 (2A, £76.60) — no transaction, no invoice.11:07:25 course #20 opened from appointment 1534; still OPEN (band undetermined). Left untouched on purpose.Two FP17 claims now exist for the same patient and same underlying treatment (1533 check-up + 1534 filling); claim #235 has no money behind it.
Why the claim duplicated but the charge did not
_raise_patient_charge (dental/services/billing.py:432) anchors the charge on the course's last completed appointment and returns None when that appointment already has a transaction → course #19's charge was suppressed (anchor 1534 already held tx #576)._raise_invoice (billing.py:337) is idempotent per (appointment, invoice_type, amount) → no second invoice.ALREADY_BILLED = FP17Claim.objects.filter(course=course) (billing.py:258-261) — a new course passes that check, so claim #235 was created anyway.Requested changes
dental/templates/dental/appointment_detail.html:23-32) — that is what invites the loop. A billed appointment should be visibly consumed, and the action refused.Test data left in place deliberately as evidence: claims #234 + #235, courses #16 (COMPLETED) / #19 (COMPLETED) / #20 (OPEN), transaction #576, invoice #20. No cleanup until Matthew decides.
Status: HANDLED 2026-09-10
Found during the live FP17 claim walkthrough (Matthew driving, practice-manager role, test-client-dental, appointment 1534).
User-facing problem. The appointment page shows a green "Treatment completed" badge on each appointment while the Course of Treatment card still reads status "Open". With no readiness cue anywhere on the page, the practice manager could not tell whether the course was finished or ready to bill — the two indicators appear to contradict each other. Matthew's words: "currently they make little sense" — he has asked for this to be fixed so the indicators are unambiguous.
Why it happens (code).
dental/templates/dental/appointment_detail.html:41-42 — "Treatment completed" is a per-appointment flag (appointment.treatment_completed_at), i.e. "this visit's treatment is done". It is not a statement about the course.appointment_detail.html:135-148 — the course card shows only status / band / opened date / clinician / exemption / visit count (plus a claim line once one exists). No progress, no readiness.dental/services/billing.py:369 _close_readiness() already computes readiness (no BOOKED appointments left in the course; every attended appointment treatment-completed) but is called only inside close_course() — the result is never rendered.appointment_detail, fp17_claim_detail, fp17_claim_list); there is no course detail page, so a multi-visit course can only be assessed by opening each appointment. The "Close course & bill" button is shown for any OPEN course regardless of readiness, so its presence is not a signal either.Not a behavioural bug. Readiness is correctly enforced server-side (the NOT_READY branch belongs to the close action), so a premature close is refused with a clear message. The defect is purely labelling + visibility: the page gives no way to see the state the server already knows.
Requested change (implementation your call).
Also noted (minor, docs-level). DentalCourseOfTreatment.opened_at help_text (models.py:400-404) says "its date fixes the patient charge", but the charge is derived from charge_date = earliest appointment date (models.py:447-452), which only falls back to opened_at when the course has no appointments. Fix the help text or the behaviour so they agree.
No dependency on OpenCode's other work; the walkthrough continues in parallel.
Status: HANDLED 2026-09-09
Full spec (read first): development/projects/dental_app/fp17-uda-completion. Research basis: wiki fp17_uda (Matthew, 2026-09-09). Testing is deliberately PAUSED until this round lands — the current engine/model is correct but coarse and misses six pieces of the real FP17/UDA model. Decisions D1–D7 all confirmed by Matthew 2026-09-09 (no open questions).
Build (U1–U6, per spec):
Sequencing: (1) commit the uncommitted M1–M6 working tree first as one atomic commit on feature/dental-nhs-accounting; (2) U1–U6 on the same branch, commit per U-step with tests; (3) full suite green in-container; migrations 0030+ applied central veripath_dental + test-client-dental; restart dental_app + dental_celery_worker; (4) run provision_erpnext_config --provision-nhs-accounts on practice 58 (ods TESTCD01) — REMEMBER --realm test-client-dental (provision defaults it to veripath and breaks the SSO deep-links); (5) HOLD push until Matthew confirms.
Scope guards: private flow / deposit / portal Path B untouched; no real EDI (mock gateway stays); no per-claim NHS invoice; patient charge 2a/b/c stays £76.60; two-month rule is validation only; exempt CoTs never charge. Tests per spec (cascade table, exempt, decimals, continuation/2-month, superannuation 4-leg, monitor math, migrations both DBs). Doc pass for Hermes after the round (one page at a time).
Status: HANDLED 2026-09-09 — M1–M6 build shipped by OpenCode on feature/dental-nhs-accounting (branch off 9d8bded4, live /app).
Full spec (read first): development/projects/dental_app/fp17-nhs-accounting-model. Corrects the accounting shape of fp17-course-billing-redesign (D4 posting + deferred P3). Research basis: wiki fp17_claims + fp17_claims_submissions — NHS money does not flow like private business: no per-claim NHS payment, no invoice to the NHS; fixed monthly contract payments (1/12 ACV), claims = UDA tokens; patient band charges collected locally are clawed back from the next monthly payment; the month-end DCS127/129-style schedule is the financial event. Private treatment = standard invoicing (the P2 private leg is CORRECT, unchanged).
Decisions (Matthew, all confirmed incl. F3b 2026-09-08): F1 claim-linked patient-charge Sales Invoices (local type TRANSACTION) post to a dedicated "NHS Patient Charges (Clearing)" account, NOT config.default_income_account (private keeps fee income); F2 fee basis (total_nhs_fee) stays the date-anchored statutory band charge, read-only after transmit; F3 schedule reconciliation built NOW (v1 = manual month-end statement entry; real DCS file import later); F3b the monthly statement posts an ERPNext Journal Entry — Dr Bank (net paid) + Dr NHS Patient Charges (Clearing) (clawback, drains the account) / Cr NHS Contract Income (gross); F4 associate pay module built now (reconciled UDAs only, never charted/claimed); F5 demo tenant reset after the build.
The build (order): M1 account-by-type sync + DentalERPNextConfig gains nhs_patient_charge_account / nhs_contract_income_account / bank ref + idempotent ERPNext account provisioning (management command + provision/bootstrap guard) + local Invoice label "Patient Charge" → "NHS Patient Charge". M2 dual-leg semantics: claims-list "Fee" column + FP17Claim.fee_amount = Leg A only (column renamed "Patient charge"), NHS fee basis (Leg B) on claim detail; uda.delivered_fee_for_claims sums total_nhs_fee (Leg B), NOT fee_amount. M3 NHS contract data (ACV → monthly payment, UDA target, per-band values) + ACCEPTED → deterministic UDA credit. M4 schedule/statement model + three-way match (fee basis vs UDAs credited vs clawback) + RECONCILED/UNMATCHED marking + the two monitors (uncollected charges due for clawback; disputed exemptions) + ERPNext journal task. M6 performer schedule (target UDAs, UDA pay rate, lab-fee split %, superannuation rate) + month-end pay statement on RECONCILED UDAs. M5 cleanup: retire dead NHS_REIMBURSABLE/PATIENT_COPAY enum values (no UI may show them), copy fixes; seed re-work so --reset yields a clean new-model baseline (absorbs the 2026-09-08 seed-fix note — archived 2026-09-09, see agents/handoff/archive/2026-09/from-hermes).
Tests: account selection by invoice type (TRANSACTION → NHS-charge account, PRIVATE → fee income, retired skipped); Payment Entry unregressed (amount = chosen invoice total — 417 fix holds across the account change); fee-column/uda unit tests redefined (exempt claim: £76.60 fee basis present while patient charge £0); M4 statement entry + match + monitors + journal Dr/Cr; M6 reconciled-only pay (zero reconciled UDAs → £0); migrations safe central veripath_dental + test-client-dental; existing suites green (course-billing / payments / band-charges / invoice-sync / financial / uda).
Scope guards: private flow UNCHANGED; no per-claim ERPNext invoice to an "NHS customer"; no resurrection of the NHS_REIMBURSABLE/PATIENT_COPAY two-invoice semantics; deposit semantics / patient-portal Path B / FP17PR capture UI untouched this round. ERPNext accounts + config wiring must be live BEFORE any re-sync/reset — Hermes runs the live re-prep + demo reset (M7) after the suite is green. Continuation rules, 2-month-rule alerts, urgent/free-repair edges, £0-charge-but-UDA treatments: still later phase (recorded on the spec page).
Status: SUPERSEDED 2026-09-26 — Matthew ruled the same day (D11 answered: fetch-and-parse); the live position is the phase 4 note at the top of the inbox. Archived as the audit trail. Matthew is settling D11 (how each month's Drug Tariff documents are found, and
whether PDF parsing is acceptable) before any build, because it decides the shape of phase 4. Please do not start the
outstanding engineering item in the 2026-09-25 note ("keep every member of every delivery with the release") until he
rules — it is a good change, but it touches the import/verify path that phase 4 may re-cut. Everything else in that note
already says do not build: the tariff parameters are not in dm+d, and the invalid-flag display is his call.
Nothing is blocked on you, and nothing is expected from you in the meantime. If you come to a run before the hold is
lifted, the safe work is whatever is already on your own list — leave this alone and carry on. The hold is lifted (or the
item re-scoped) by a note here, and the evidence behind the hold is
Central Reference Data §14 — the Drug Tariff documents are
now mapped: discovery is "read the NHSBSA hub page" (the media folder is the upload month, not the tariff month), the
text layer is clean, and the deduction scale is three flat
rates since January 2024 (9.85% appliances / 20% generics / 5% brands), not the old graduated table. (Correction,
same day: an earlier line here said the parameters need "~300 KB of parts" — wrong. Part V is not published as a
separate part, so the full 6.25 MB edition is required. See the note above and plan §14's correction.)
Status: HANDLED 2026-09-26 — the audit stands unchanged. Of the two 'engineering items', one was withdrawn as a false defect (TRUD's own filename) and the other (keep every member of every delivery with the release) is still held and carried forward in the 2026-09-26 phase 4 note. D11 was answered 2026-09-26 (fetch-and-parse).
Full record: Central Reference Data §11. Prompted by Matthew's
question about where the reimbursement data comes from; he ran the archive inventory on his own machine and pasted the
output back.
1. The audit (measured, not inferred). His original download — ~/Desktop/nhsbsa_dmd_9.2.0_20260921000001.zip,
16,880,148 bytes, 16 members — holds the 7 dm+d XML files we imported (every one the same byte size as ours), the
7 XSD schemas (amp_v2_3.xsd … vtm_v2_3.xsd, 35 KB total), docs/dmd_extract_comms_9_2_0.pdf (287,907 bytes —
the dm+d v9.2.0 release communications), and — crucially — the GTIN data is not a member of the archive at all: it
arrives as a nested week392026-r2_3-GTIN.zip (1,303,821 bytes). The copy on the app host (16,356,672 bytes) differs by
523,476 bytes because it kept the GTIN member expanded as f_gtin2_0170926.xml and dropped the XSDs and the PDF.
2. Two items for you, both small:
f_gtin2_0170926.xml is the delivery's own name. The nestedweek392026-r2_3-GTIN.zip was opened on Matthew's machine: it holds f_gtin2_0170926.xml (11,990,722 bytes — the sameMANIFEST.txt at the sameamp_v2_3.xsd … vtm_v2_3.xsd, schema v2.3, 35 KB total), docs/dmd_extract_comms_9_2_0.pdf,gtin_v2_0.xsd, docs/dmd_extract_comms_GTIN_9_2_0.pdf anddocs/GTIN_Transfer_Tracking_Log.xlsx. They are the structural contract and the release's own change notes. If you agree,3. The tariff finding — it changes phase 4's source, not its scope. Searched the whole archive and each member
individually: no deduction, no discount scale, no activity fee / single activity fee anywhere; DTINFO occurs in
f_vmpp2 only (8,686 records — pay category, price, previous price, date), and the only other tariff strings are the
13 pay-category names in f_lookup2's DT_PAYMENT_CATEGORY dictionary. So the reimbursement prices are already ours
via dm+d (Cat A 489, Cat C 2,432, Cat M 624, Cat H 19, VIIIB 474, VIIID 55 priced; Part IXa only 337 of 4,354;
IXb/IXc/IXr/Part X unpriced or stale) — but the discount deduction scale and the Single Activity Fee are not in the TRUD
delivery and never were: they exist only in the monthly NHSBSA Drug Tariff documents. Phase 4 therefore needs the
document route (monthly discovery + parse) and nothing from TRUD; D11 (how each month's files are found, and whether
PDF parsing is acceptable) is still Matthew's call — do not build phase 4 yet.
4. Unchanged, and still the only blocker on phase 2's live call: TRUD_API_KEY is not set, so the 03:15 pull keeps
aborting not_configured (latest 2026-09-25 03:15) — dm+d reads Current only because it was imported by hand. The TRUD
account remains a human action (D12).
5. Forward look from the v9.2.0 release note (read 2026-09-25) — three items, none urgent:
snomed_descriptions.term is a text column, so storage is fine (checked live) — theAnd one finding from the same note: the invalid flag is stored and never surfaced. The release's per-concept invalid
flag is loaded (invalid on VTM/VMP/VMPP/AMP/AMPP/ingredient), and the live release holds 4,200 invalid AMPPs of
187,156 (AMP 3,285, VMPP 1,161, VMP 620, VTM 106). pharmacy_medicines/catalogue.py filters on the live published release
and nothing else — no invalid term anywhere in the module — so an invalidated pack can be searched, opened and shown
with its price, basis and GTINs, unmarked. Not a spec defect (v1 is a capped name search over the live release) and
not yours to decide: hiding, flagging or leaving them is Matthew's display call, so please do not change it
unprompted. It is on the record at
§12.1 with the counts, and the mid-October Senokot
invalidation (AMP 38871711000001108 → 3386711000001106) makes it concrete.
27dc604) — nothing handed backStatus: HANDLED 2026-09-23 — record only; nothing is asked of you.
Both commits landed. Part 2: pharmacy_booking_app 9722205 → 27dc604 by git bundle, pharmacy_app +
both Celery containers restarted, pharmacy_medicines.0001_initial applied on the default and the pharmacy tenant
("Applying pharmacy_medicines.0001_initial... OK"), app healthy. Part 1 nit: gp_booking_app 91b9399 → 45b8dd8,
containers restarted, healthy.
All six spec §5 criteria verified live on the deployed code (read as a signed-in pharmacy user through the app, and
at the ORM):
| §5 | Evidence |
|---|---|
| 1. Reachable + labelled everywhere | anonymous /medicines/ → 302 on both the apex and the client host; signed in → 200; the source line "NHSBSA dm+d, distributed by TRUD — release 3170926, published 21/09/2026" and the not-a-quotation sentence present on the list, detail and about pages |
2. Price / honest absence / never £0.00 |
priced pack 1327911000001107 renders £44.20; unpriced pack 1328011000001109 renders "no published price"; £0.00 appears nowhere in any rendered page sampled; 0 zero-priced rows in the live release |
| 3. Live-release rows only | every result carried release_id = 1 (the live release); a pack pk outside it → 404; no other release is held here, so the superseded case is covered by your tests rather than a live state |
| 4. One capped query, measured | catalogue.search('paracetam') → 25 rows, truncated (614 packs match, so the cap is real) with the SQL showing the release filter and LIMIT 26; measured 186 ms for the query, 317 ms for the full request |
| 5. No write path | grep of the module: no save/create/update/delete outside tests; pharmacy_medicines suite re-run by me: 18 tests OK |
| 6. Same rows per tenant | demo.rp.two (apex) and tc.tech.one (org test-client-pharmacy) both get 200 with the same 25 pack links for ?q=paracetam |
On your open question about the index: measured here, 186 ms server-side / 317 ms end-to-end for a 614-match term is
comfortable for a lookup screen, so no index is needed yet — the pg_trgm GIN index stays the obvious first
modification if it is ever felt, and it belongs in the platform app's migrations as you said.
Part 1 nit confirmed fixed live: the dm+d card now reads "Last successful fetch: never — the live release was
imported manually (21/09/2026)", and the two feeds with no routine have dropped the line entirely.
Status: HANDLED 2026-09-23 — built by OpenCode (pharmacy_booking_app @ 27dc604, branch feature/pharmacy-medicines), then deployed and verified live by Hermes (see the note above). Matthew has seen Part 1 on the live backend, and his direction is that the
data is now displayed to pharmacy staff in the pharmacy application — basic first, modified afterwards. D1 is
answered and recorded on the parent plan.
Spec: Data Feeds — Pharmacy View — parent plan
§5.3 (the read route), §5.6 (labelling: a licence condition, not polish), D1.
The finding that keeps it small — verified live from inside pharmacy_app: the pharmacy application's
default connection is the platform database that holds the store. From the pharmacy container I read all 16
reference_data tables, the live release (dm+d 3170926, published 2026-09-21), 167,307 AMP rows, 187,156
AMPP rows and the price split — 92,294 priced, 94,862 none, 0 zeros. So read-through needs no new
credential, no new network path and no copy; the plan's §5.3 leaning flips from cached-subset to read-through for v1
and that is now recorded on the plan with the reason.
What to build (v1, deliberately minimal). A read-only managed=False mirror of only the tables the view needs
(pack, product, price, and the release/source rows for the label); live-release rows only; a new pharmacy-app entry
/medicines/ — "Product information" — with one search box (name, three characters or more, capped at 25,
server-side) and a detail page per pack; the price in £ where dm+d has one and "no published price" where it does
not (never £0.00); the §5.6 label on every page, generated from the release record; no export in v1 (print
inherits the label); signed-in pharmacy users, no new permission, no tenant filtering (it reads central rows only).
Nothing in the view may write to any reference_data table — and any index the search needs belongs in the platform
app's migrations, never a write to the store's tables from the pharmacy side.
Acceptance is spec §5 (six criteria): label present on every page naming the live release; the absence case
rendered as text; live-release rows only; one capped query with the response time measured on the real 187k-row
table; no write path; identical results for every tenant.
Deploy: pharmacy_app on the app host, repo bind-mounted at /app — commit and restart (a separate deployment
from gp_booking_app). Forgejo is unreachable from the app host, so push and I carry the commit as a git bundle;
any platform-app migration (e.g. an index) travels the same way into gp_booking_app. I deploy and verify live and
record the evidence on spec §7.
91b9399) — one wording nitStatus: HANDLED 2026-09-23 — the wording nit was fixed by OpenCode (gp_booking_app @ 45b8dd8, branch feature/reference-feed-status) and redeployed and verified live (see the newest note above).
Deployed. 82871c4 → 91b9399 on the app host via git bundle (Forgejo still unreachable from there),
containers restarted, reference_data.0005_referencesource_routine applied by the entrypoint ("Applying … OK"), app
healthy.
Verified live, against spec §4 — read as a platform admin and at the ORM:
routine: dmd = trud_pull, drug_tariff = none, price_concessions = none — the 0005 backfill is correctkey_status() returns None for both monthly feeds and3170926, published 21/09/2026, age 2 days, next expected 28/09/2026); bothsecondary (grey), with no warning and no danger.feed_state(dmd, today=2026-10-30) → Not running — no key; the overduetest_health.py cases.Fetch now and Purge old rows untouched.Nit — wording, not behaviour (your call whether it is worth a change). On the per-feed cards "Last successful
fetch: never" appears for all three feeds. For Drug Tariff and Price Concessions it is meaningless — no routine
exists, and the card already reads Not automated yet — and for dm+d it is true (the live release was imported by
hand from the file Matthew fetched; no pull has ever succeeded) but reads oddly beside a green Current. Suggested:
show the line only where a routine exists, and for dm+d state the truth rather than the bare "never" — e.g.
"never — the live release was imported manually (21/09/2026)".
Status: HANDLED 2026-09-23 — built by OpenCode (91b9399, branch feature/reference-feed-status), then
deployed and verified live by Hermes; see the newest note above and from-opencode. Matthew has confirmed the scope in his own words: "so long as I can see
everything is basically running and up-to-date then that is all we need right now … as basic as it can be will be
enough." No new machinery, no new controls.
Spec: Data Feeds — Backend Status View — parent
Central Reference Data (§5.12 the management view,
phases 1–2, D13 where it lives, D5 who may act).
The whole ask in one paragraph. The store and the four screens are live and correct; what the surface cannot do is
answer "is everything running and up to date?" in one look, because the TRUD key check is applied globally:
Drug Tariff and Price Concessions both report "No API key configured" and raise "the scheduled pull is idle" when
neither uses TRUD nor has any routine at all — two of today's three alerts are noise that reads as a fault. So:
give ReferenceSource an explicit routine state (recommended routine = trud_pull / none, set by
seed_reference_sources — dm+d only today, phases 4–5 flip it), show credential state only where a routine uses
one, add one plain state per feed (Current / Not running — no key / Release overdue / Not automated yet), add a
one-line "all configured feeds are current" / "N feed(s) need attention" summary plus a per-feed one-liner (last
successful fetch, live release and date, next expected) on the landing page, and split alerts into action-needed vs
informational. Read-only: no per-feed run buttons — controls come after this is in place, in his order.
Acceptance: the only credential alert is dm+d's, and the other two feeds mention no key anywhere; a
routine = none feed reads "Not automated yet" and never warns, even with no release; dm+d reads Current today
(release 3170926, published 21/09/2026, next expected 28/09/2026) and flips correctly to overdue and to not-running;
the four screens stay 403 to anonymous and render for a platform admin.
Deploy route reminder: the app host holds the repo as a git checkout bind-mounted at /app, so a restart picks
a code change up (no rebuild) — but Forgejo is not reachable from the app host, so push your branch and I will carry
the commit across as a git bundle, as with phase 1 and 82871c4. I deploy and verify live; the evidence lands on
the spec page §6 and in from-hermes.
82871c4)Status: HANDLED 2026-09-23 — record only; nothing is asked of you, and nothing of yours is running.
Landed. /root/work/gp_booking_app on the app host fast-forwarded 2301095 → 82871c4 — Forgejo is still
unreachable from that host, so it travelled as a git bundle (one commit, 3.6 KB; the host tree kept its
detached-checkout shape). No tracked modifications to lose. The three containers were restarted (the repo is
bind-mounted at /app), the entrypoint ran migrate + collectstatic, gp_booking_app came back healthy, and the
platform health endpoint returns 200.
Nit 1 (the publication date) — verified against the real thing. In the deployed container,
derive_publication_date(hint='nhsbsa_dmd_9.2.0_20260921000001.zip') → 2026-09-21, exactly the date I set by hand
last session from TRUD's archive name — the code now derives what I had to decide manually. An ISO directory name
(…/2026-09-22) → 2026-09-22; a name with no date at all → None, i.e. the warning branch, so freshness is
never silently blank. The deployed tree's own suite re-run by me: 61 tests OK (1 skipped — the guarded
real-release test), and the run log carries the new command-path line "Publication date: 2026-09-21" from
test_import_command_derives_a_publication_date. The freshness figures came through the redeploy intact: release
3170926, publication date 2026-09-21, age 2 days, next expected 2026-09-28, overdue False.
Nit 2 — my error, not your gap. I told you an unconfigured fetch "records no attempt row". That was wrong.
fetch_latest_release creates the attempt at the top and its finally block saves it with finished_at, so the live
store already carried a not_configured row from 15:30 (pre-fix) alongside the one my run produced at 15:48
(post-fix) — identical in shape. Your explicit save is harmless and makes the intent obvious, and the command-path
test is a genuine gain, but I sent you after something that was not broken; the plan page now says so.
Live checks after the redeploy. /infrastructure/reference-data/, …/health/ and …/upload/ → 403 to
anonymous; the health page renders 200 for a platform admin with the not-configured attempt row visible (not
the "No fetch attempts yet" placeholder). The live TRUD pull remains the one unverified step and still needs Matthew's
account (D12) — your MANIFEST.txt question stays open until then.
Status: HANDLED 2026-09-23 — both nits fixed by OpenCode (82871c4, phase 2 branch): the CLI and
upload paths now derive publication_date (archive/directory name → manifest Retrieved: → warn), and a
not-configured fetch persists its attempt row (covered by a command-path test). 61 tests OK. Nothing else
outstanding; Phase 3 waits on D1.
Landed, in your order, on the app host: 438e565 (phase 1) first, verified on its own, then 2301095 (phase 2).
Both are live.
How it went, so you know the ground rules now:
172.18.0.16:2222 refuses), so the commits travelled as a gitgit init + fetch <bundle> + checkout -f), the old tree wasconfig/secrets was untouched.CHANNEL_LAYER_URL and NHS_NOTIFY_API_KEY — taken from what/app (as are media andstaticfiles), so a restart does pick up code changes. The image does still hold a build-time copy, which ismigrate + collectstatic handled the rest.Phase 1 — live evidence. Imported through your own command (the lifecycle the upload screen drives): "Verified 8
files, 238,847,891 bytes, release 3170926" → "752,256 rows across 8 files" → "Release 3170926 is now live." Then
reference_data_check --path … → "All checks passed.", with every count matching the plan's independently derived
figures, integrity re-proved against the manifest, 10 closure checks 0 dangling, 0 packs with no price row,
0 prices stored as zero. A second import is a no-op ("already held as Published (live)"). dmd has 1 release
and 1 live. The audit trail carries imported + published and a licence snapshot naming Open Government Licence for
TRUD. I exercised your consent gate live: publishing price_concessions raised "Price Concessions cannot be
published until its consent condition is recorded" inside a transaction that rolled back, so nothing persisted.
Anonymous hits on /infrastructure/reference-data/ and …/upload/ return 403; the app is healthy.
Phase 2 — live evidence. 0004 applied; release list, …/health/ and …/upload/ all resolve behind the
platform-admin guard. The health surface reports release 3170926, age 2 days, next expected 2026-09-28,
overdue False, key not_configured, with the alert "No API key configured; the scheduled pull is idle". A fetch
with no key refuses cleanly ("TRUD_API_KEY is not set; the weekly pull cannot run", exit 0). reference_data_purge →
"0 release(s) purged; manifests and hashes retained." Beat entries registered (pull 03:15, health 06:00, purge
weekly). The live TRUD call is the one thing I could not verify — it needs the account (D12, Matthew). Your question
about the real archive's MANIFEST.txt is therefore still open; note that the held release does carry one and all 8
files verified against it, and your code handles the no-manifest case by falling back to the archive hash.
Two nits for you (neither is a blocker, neither changes behaviour I rely on):
import_dmd_release <zip> --publish left publication_dateage_days, next_expected) until I set it. The scheduled pull sets it--publication-date or derive it. I set thenhsbsa_dmd_9.2.0_20260921000001.zip); theReferenceFetchAttempt, so the attempt log is empty for the "not configured" case. The health surface reports it, soNot yours yet: Phase 3 (client availability) waits on D1 (which surface shows the data first — I have
recommended a read-only product-information view) and on the account for the live pull.
Status: HANDLED 2026-09-23 — redaction fixed by OpenCode (438e565), deployed and verified live by Hermes; the
rest of this note's analysis stands as the record. Superseded by the note above.
Hermes verified Phase 1 without deploying it (766a46e, in a worktree on VPS1 — no production change). I re-ran
your suite myself: REFDATA_DMD_PATH=/srv/datasets/dmd/2026-09-22 scripts/run_reference_data_tests.sh →
33 tests OK in 80s, the real-release test included, and reference_data_check passed twice: integrity re-proved
against the manifest, all 10 closure checks 0 dangling, 0 packs without a price row, 0 prices stored as zero. Your
stored counts match my independent file-side counts exactly — VTM 3,250 · VMP 24,662 · VMPP 37,397 · AMP 167,307 ·
AMPP 187,156 · prices 187,156 · GTIN 101,309 · substances 4,532 · VPI 26,942 · Drug Tariff 8,686 · lookup 3,859 — and
pence()'s "a zero is never a price" is exactly what the plan requires. The consent gate is real and structural
(consent_required / can_publish, and the publish path raising on it). (One correction for the record: the
VMP→VTM "no link" case is 7,019 VMPs, and it is the publisher's own data, not a gap — the checker reports it as
no link, which is right.)
⚠️ The blocker: the secret-scan redaction left literal ***REMOVED*** values in three tracked files, two of them
live code paths. The deployed tree on the app host still holds the working hardcoded values, so deploying the
branch would replace working secrets with a placeholder:
appointments/views.py — the two NHSNotifyService(api_key=…) calls. The production env defines no Notifyenv.production carries NHS_API_KEY for PDS, which is a different thing), so today's workingdsp_clinic/settings.py — CHANNEL_LAYERS falls back to a placeholder redis://:***REMOVED***@…, andCHANNEL_LAYER_URL is not in the production env, so the fallback is what runs: the ASGI channel layer wouldtenancy/migrations/0002_seed_sectors_and_tenants.py — db_password='***REMOVED***' in the seeded tenant rows.Fix (yours, small): take those three from the environment instead of a literal — CHANNEL_LAYER_URL, and a Notify
key variable such as NHS_NOTIFY_API_KEY — and tell Matthew the two names to add to the host-local
config/secrets/env.production. No secret value belongs in the repo or in your report. Then the branch is
deployable and I will land it and verify it live.
A second problem, now settled: the deploy path (APPROVED by Matthew 2026-09-23, D14). The app host's source tree
(/root/work/gp_booking_app) is not a git checkout (no .git) and is not a clean copy of any commit: 8 tracked
files differ from the branch's parent (several with July/August mtimes) and there are ~21 local-only files (template
.baks, stray root-level html, a dev TLS key under nginx/ssl/). scripts/deploy.sh is stale — it expects
/var/www/html, runs git pull origin main, and restarts the container, but the code is baked into the image, so a
restart would not pick up new code at all. Approved method: make the app host a git
checkout (clone from Forgejo; config/secrets stays outside version control), deploy as
git fetch && git checkout <commit> → docker compose build app celery_worker celery_beat → up -d, and clean the
local-only junk deliberately rather than blindly. Two things before I can land anything: your redaction fix above,
and the two env variable names for Matthew to add host-locally.
Phase 2 — one need cleared, one still outstanding. Egress to TRUD works from the app host (checked today: the
landing page answers 302, the API path answers its own {"apiVersion":"1", …} envelope), so that need is met. What
Phase 2 still needs is the TRUD account with item 24 subscribed and its licence accepted (D12 — Matthew).
There is no API key to apply for — Matthew corrected me on this and he is right: TRUD derives the key from the
account's email address and password and shows it on the account's Your account page, so the gate is the account
and its item subscription, and whoever runs the pull copies that derived key into the host-local secret store. The
dm+d item id is 24 and the release-list endpoint is
/trud/api/v1/keys/{key}/items/24/releases[?latest]. Parts of Phase 2 that do not need the account can proceed now:
the freshness/health surface in the management view, retention/rollback, and detection of the key failing (TRUD
invalidates the key if the account's email or password changes).
For the record: the deployed tree and the image built from it still contain the pre-redaction plaintext secrets
the repo has since cleaned. That is the platform's known open credential exposure; the first proper deploy is the
moment to close it rather than carry it into another image.
Status: HANDLED 2026-09-23 — Phase 1 was built by OpenCode (766a46e) and verified by Hermes (33/33 incl. the real
release); deployment is blocked on the redaction placeholders above. Kept as the audit trail.
The plan: Central Reference Data — approved by
Matthew 2026-09-23. It is the build plan for holding NHS reference data (dm+d first) centrally and publishing it to
every pharmacy client. Read §1 (the decisions as given), §5 (the shape), §6 (the phases — names now approved) and §7
(the decisions, each answered or accepted as recommended). Housekeeping: §5.5 governs, §5.10 is the reconciliation
record, §5.12 is the management view.
Your phase is Phase 1 — the store and the loader, and it needs nothing from outside. The dm+d release of
2026-09-22 is already on disk at /srv/datasets/dmd/2026-09-22 (8 files, 228 MB) with its MANIFEST.txt, and it is
the first real release to import. Scope: the central store (§5.1), the release model, the upload screen with
verify → preview → confirm → publish (§5.2), and the backend management view that controls it (§5.12 — inside
the backend's existing /infrastructure/ area, behind PlatformAdminRequiredMixin, platform admins only; D13).
Acceptance criteria are in §6 — the ones that matter: the held release imports and publishes; a re-upload is a
no-op; a partial import is invisible to readers; publish and rollback are audited; a checker proves counts
and integrity against the manifest.
Four things that will save you a wasted build:
<AMPP> —MEDICINAL_PRODUCT_PRICE → PRICE_INFO section keyed by the same pack id; the basis elementPRICE_BASISCD (0001–0004) translated by the lookup file's PRICE_BASIS table; the real filenames aref_ampp2_<release>.xml and friends. The instruction block targets a host that does not resolve (nhsbsa.net) andgrep/sort/comm over extracted ids — 25 seconds — rather thancompose up -d alone leaves the old code serving; restart theNot yours, and not blocking Phase 1: the TRUD account, its subscription to item 24 and the API key (D12 — Matthew,
needed before Phase 2); CPE consent to publish concession data (D10 — Matthew); the Drug Tariff format decision (D11).
The labelling requirement is a licence condition, not styling (§5.6). Every surface and every export carries the
source, the publisher and the live release identity and date; where dm+d publishes no price the screen says so and
never shows £0.00. Licences, already settled — record them, don't re-derive (§5.11): dm+d = Open Government
Licence for TRUD; the Drug Tariff = NHSBSA copyright, reusable under OGL v3 with their prescribed attribution
statement; CPE price concessions = PSNC copyright, no re-publication without prior consent.
Handoff: Hermes is stopping now, so this is async as usual — do not run concurrently. Leave your build report in
from-opencode when Phase 1 is ready and Hermes will verify it live, as with the pricing stages.
Status: HANDLED 2026-09-22 — built by OpenCode as specified (commit 9722205), deployed by Hermes and verified
live 49/49; the record is on
supply-orders-pricing-phase4-value. W1, W2,
W3 and W5 done; W4 answered in writing and, per that answer, its column is not built. The build as delivered,
and the W4 answer, are in from-opencode; the verification is on the page above.
Spec: supply-orders-pricing-phase4-value
· Parent plan: supply-orders-part3-pricing.
Stages 1–3 are live and verified (abd61de is what you last handed over): the import 35/35, the visible prices 24/24,
price-driven ordering 24/24. Verify the three verifiers still pass before you finish — and if one of them needs
changing to stay true, say so in your note rather than changing behaviour to please a checker.
The starting positions below are what Matthew has not amended (he is walking through stages 1–3 before he answers
anything), so build them as written and flag each one as reversible in a sentence.
W1 — the value, from the snapshots and nothing else. No new tables: the numbers come from
OrderLine.unit_price / price_basis / price_recorded_at / price_supplier, which stage 3 already writes. Show it
on the order list and the order detail; keep the per-supplier group values on the build screen.
The trap: an order with no snapshots must read "value not known", not "£0.00" — every order created before stage 3
is in that state (ORD-20260921-D123C9 is live), and a total that silently counts them as zero is worse than no
total. Never invent a value, never backfill one, and a partial total must say so.
W2 — the minimum order value becomes a live advisory. Supplier.minimum_order_value is documented in the code
today as "display only — the app holds no purchase prices and does not check it"; that help text becomes a lie the
moment you add the warning, so change the help text in the same commit. The warning appears per supplier group on
the build screen (value against the minimum, and the shortfall) and once on the order detail. It never blocks,
never changes a quantity and never adds a line — a knowingly short order that pays a delivery charge is a
commercial decision. Blank minimum → no warning (not a comparison against zero); unknown value (W1's case) → say
so rather than compare a partial number.
W3 — the copy. The supplier field help, the build-screen note and the C6 guide must all say the same thing: the
minimum is the wholesaler's contractual threshold, shown for the pharmacist's judgement, not enforced by the app.
A warning that reads like a rule changes behaviour wrongly, so the wording is part of the deliverable.
W4 — the margin column: do not build a number. Deliverable is a written answer on the spec page to the three
questions: what reimbursement price we can legitimately display per stock item and from where (the pharmacy's own
figures / the licensed Drug Tariff / nothing), what links a bought pack to what was dispensed, and what the column
says in each of the three states. Today the demo's reimbursement items are separate fabricated items priced from a
synthetic list — a margin computed now would be a fabricated price against a real purchase price.
W5 — tests and the boundary. Totals from the snapshots including the no-snapshot case; the advisory firing,
silent on a blank minimum, silent/truthful on an unknown value, and never blocking; the Counter Assistant refused
every value surface (the value is commercial data — same gate as prices: RP / Technician / BM) and no value text in
a page they can load; the export file still two columns with no value in it; and the standing boundary — no
ERPNext client constructed, no position-cache write, nothing central.
Report back in from-opencode with the states to check live (a group below the seeded minimum and one above it,
an order with no value, the Counter Assistant refusals), and note W4's answer separately — I verify it all live on
test-client-pharmacy afterwards.
Status: HANDLED 2026-09-22 — OpenCode did the first option in abd61de: seed_pharmacy_stock now seeds
DEMO-STALE-001 (only a stale price) and DEMO-ONEDAY-001 (only a one-working-day price), and
seed_pharmacy_prices adds a stale-only supplier (Four). Verified live after deploy: the low-stock report renders
Stale (struck through, marked not usable, with its age) and One working day old beside Current, the
build screen falls back with "no usable price — proposed from the preferred supplier" for the stale-only item, and
both verifiers now pass — stage 2 24/24 and stage 3 24/24. My own two assertions had also been wrong in form (the
cell class is lowercase and a row carries two stale cells), so the fix was to the checker, not the app.
Verified live today (22/24) — see the record on
supply-orders-pricing-phase2-visible.
Everything passes except the two checks about a stale price being visible, and the reason is the data/columns, not
the rendering:
Current is ever rendered on the low-stock or expiry reports;So the rule is implemented and tested but cannot be shown to the pharmacy with the demo data as seeded, which
matters because the point of the example data is learning the procedure.
Please either seed a case where the best price is old — an item no other supplier covers, or an item whose only
file is stale (one working day old as well, so all three states are reachable) — or, if the intent is that this
column only ever describes the best price, say so and I will record that as the behaviour. Either answer is fine; the
second is a sentence, the first is a small seed change.
Also still true and worth keeping: stage 1's verifier must keep passing 35/35 (it does), the order-building screen
must stay price-free (it is), and reading must write nothing (proven: audit 176 / files 4 / rows 9 / cache 6 unchanged
before and after the read-only run).
Status: HANDLED 2026-09-22 — built as 7ddddf1, deployed and verified live by Hermes (22/24; the two failures are
the demo-data finding in the note above). The record is on
supply-orders-pricing-phase2-visible.
Spec: supply-orders-pricing-phase2-visible
· Parent plan: supply-orders-part3-pricing.
Start from what is deployed: stage 1 is live — the app engine is on dev at 1a01d8c, pharmacy.0018 and
pharmacy_stock.0004 are applied on the apex and on the client tenant, and the live verification passed 35/35. Build
stage 2 on that, and leave the import's behaviour alone.
What stage 2 is, in one line: the prices stage 1 imported become visible on the stock screens — read-only, no
ordering change, no guardrail, no dm+d price, and no price on the order-building screen (that is stage 3; the live
verifier checks the build screen stays free of it, so it should keep passing).
Points where the spec is deliberate, so they do not get smoothed over:
SupplierPriceFile / SupplierPrice. The only schema touch isStockSettings.bank_holidays (migration pharmacy_stock.0005): weekends are arithmetic, holidays cannot be£0.00. This is the honesty rule the whole feature rests on.NOT_STATED price carries its basis beside it and is never presented as/stock/suppliers/<pk>/ — the supplier's own details plus its price-file history (every importWhen it is deployed, report back in from-opencode with the screens and the states shown. I will then verify live on
test-client-pharmacy with the seeded example files (including a backdated file so the stale state is real), check the
403s and the "no price in a Counter Assistant page" rule, and confirm nothing wrote on a read. I will not be running
while you are.
Status: HANDLED 2026-09-22 — built (6b2c38c + the copy nit 1a01d8c), deployed by Hermes and verified live
35/35; the record is on
supply-orders-pricing-phase1-import.
Spec: supply-orders-pricing-phase1-import
· Parent plan (five stages): supply-orders-part3-pricing.
Matthew authorised the start today and expects "further modifications post-completion when we start using it" —
so build against example data only, and never a real wholesaler price list.
What stage 1 is, in one line: a Business Manager uploads a wholesaler's price file, confirms which of the file's
actual columns is the code and which is the price, and the app records what it did with every line — matched,
unmatched or rejected. Nothing else changes: no price appears on the item, supplier, low-stock, order or receipt
screens in this stage (that is stage 2).
Points where the spec is deliberate, so they do not get smoothed over:
pharmacy_stock.0004 (SupplierPriceFile, SupplierPrice), audit actions andpharmacy.0018, and a new Business-Manager-only gate PharmacyPriceImportRequiredMixin incore/permissions.py mirroring the staff-user precedent. Read surfaces keep PharmacyStockManageRequiredMixinprice_profiles.py holdsorder_profiles.py. The price basis (per pack / per unit / not stated) is captured,pip_code then barcode, leading zeros preserved, no fuzzy matching; a line that matches nothingmatched + unmatched + rejected == rows_read.price_date supersedes the first (record, counts and hashseed_pharmacy_prices --tenant <slug> that imports them through the real service, deliberately including a duplicate code, an unmatched£1,234.56 price and a bad price, and a second file to prove supersede.StockPositionCache write, zero pricingStatus: HANDLED 2026-09-22 — the copy nit was fixed by 1a01d8c (the build screen's help text now says lines are
dropped on the draft's edit screen), and the guide was amended to match the screen.
Verified on test-client-pharmacy this afternoon, end to end, as tc.tech.one:
ORD-20260921-898C9C, 3 lines) and exported it twice. The downloaded file was exactly the profile,ProductCode,Quantity then 2345678,32, 0123456,21,DEMO-BARCODE-0003,5 — the leading zero survives and the PIP-less item falls back to its barcode.Content-Disposition: … synthetic-wholesaler-demo-order-ORD-20260921-898C9C.csv, text/csv; charset=utf-8, withnosniff.ORDER_CREATED, then ORDER_EXPORTED ×2 with the actor and line count.32.5) is refused at build time without creating an order.Received / Difference columns (empty until a receipt isThe nit. order_build.html's copy says "The app proposes quantities — edit or drop any line before saving", but
the build screen has no way to drop a line — every suggested item is posted as a hidden item plus a quantity
box, and a zero or empty quantity is refused rather than treated as a drop. Dropping is only offered on the next
screen (the draft's order_edit.html, which has the Drop checkbox). Either add a Drop column to the build screen or
reword the copy; the guide currently explains the discrepancy rather than the reader discovering it.