Status: OPEN — and it now holds a guide. Raised by Matthew on 2026-09-19 during the review of
B5 — Endorse a Prescription, after we established that the
app's "endorsement reference" has no counterpart in NHSBSA's process. His instruction: treat it as a negative
action, because this subject matter is regulated and must be conducted accordingly — and check what else has been
asserted without a source.
The governing principle, set by Matthew 2026-09-19: the authority decides what the app's controls and
features are. Where NHSBSA is the authority for a control, the app follows NHSBSA — the app does not invent its own
version of a regulated artefact, and neither does a guide. This page is a development record (it concerns how
apps are built), deliberately not a DSPT evidence item.
The class of defect this audit looks for: content that asserts, or can reasonably be read as asserting, an
NHS or regulatory requirement — a code, a certificate, a form, an identifier, a category, a duty — where the app
alone is the only source.
Numbering: these are the register's own finding numbers ("Finding 1"…). They are
not the guide groups A–H on the Coverage Plan.
Finding. The app collected a free-text "Endorsement reference (local)" and matched receipts to claims on it. No
such artefact exists in the NHSBSA process: endorsement in EPS is the Electronic Reimbursement Endorsement Message,
whose endorsement code field is mandatory and must carry a real code (BB, MF, NCSO, SSP, XP, PD(n), IP, RC, SP,
ED, price endorsements) or be populated with NDEC — "No Dispenser Endorsement Code" — as positive confirmation
that no other endorsement applies. The claim itself is the monthly submission document (FP34C for pharmacy
contractors, submitted digitally via MYS), personalised with a contractor-specific barcode; payment returns as
electronic FP34 payment schedule data. None of these is a per-prescription reference typed by the pharmacy.
Evidence. EndorsementRecord.endorsement_reference — CharField(max_length=128, blank=True), no uniqueness, no
validation; EndorseForm.endorsement_reference — required=False, label "Endorsement reference (local)";
pharmacy_accounting/services._expected_for_reference — matched on it first, taking .first(), so a duplicated
hand-typed value could silently attach a receipt to the wrong claim; the expected-claims list never displays the value,
so the person typing it on a receipt cannot read it from the screen.
Root cause. A placeholder added for a deferred capability (D3: local record only, no NHSBSA transmission), then
documented as if the artefact were real. It was never checked against the Drug Tariff or NHSBSA guidance because it
looked like an internal convenience.
Corrective action — DONE / UNDERWAY.
agents/handoff/from-hermes).Finding. The dispense screen offered eight exemption categories, none of which is the authority's code set. NHSBSA
publishes the mandatory coded values for the EPS message (Appendix B). The app's list was a hand-made shorthand:
an invented "Other", six legally distinct exemptions collapsed into one "Income-based benefit", real categories
absent entirely, and no way to record that a charge was paid.
RULED 2026-09-19 — the app follows NHSBSA. The eight free-text categories are replaced by the NHSBSA EPS
exemption and charge-paid coded values. Build spec issued to OpenCode.
Authoritative values (EPS message code — description, England):
| Code | Meaning | Note |
|---|---|---|
| 0000 | Patient has paid appropriate charges at old rate | charge paid |
| 0001 | Patient has paid appropriate charges | charge paid |
| 0002 | Is 60 years of age or over or is under 16 | |
| 0003 | 16, 17 or 18 and in full-time education | |
| 0004 | Is 60 years of age or over | OBSOLETE |
| 0005 | Maternity exemption certificate | |
| 0006 | Medical exemption certificate | |
| 0007 | Prescription prepayment certificate | |
| 0008 | Prescription exemption certificate issued by Ministry of Defence | |
| 0009 | HC2 (full help) certificate | |
| 0010 | Was prescribed free-of-charge contraceptives | EPS use only |
| 0011 | Income Support or income-related Employment and Support Allowance | IS ceases 15 Apr 2026; ESA remains |
| 0012 | Income based Job Seeker's Allowance | OBSOLETE from 16 April 2026 |
| 0013 | Tax credit exemption certificate | OBSOLETE |
| 0014 | Pension Credit Guarantee Credit (including partners) | |
| 0015 | Was prescribed free-of-charge HMP/prisoners/secure accommodation medication | EPS use only |
| 0016 | Universal Credit and meets the criteria | |
| 0017 | Free supply for specified use | EPS use only |
| 0018 | Was prescribed free-of-charge mental health medication | OBSOLETE |
| 0019 | Was prescribed free-of-charge TB medication | OBSOLETE |
| 0020 | HRT only prescription prepayment certificate | |
| 1001 / 1002 | Welsh Prescription, Welsh Dispenser – No Charge / Welsh Entitlement Card | Wales only |
Two rules the authority states and the app must honour: only one value can be populated, and where a prescription
mixes free-of-charge items with others, the status of the other items applies.
Why it mattered. Today the declaration is a local record only (D3), so nothing wrong was transmitted. But the
guide presented the invented list as the categories a pharmacist chooses between, and the model could never produce
an EPS message code when the real claim is built. "Other" was an invented category inside a regulated declaration.
What the authority requires. SI 2008/2789, reg 5(1) — the pharmacy record must include: (a) the responsible
pharmacist's name; (b) any number associated with their registration in the Register of Pharmacists; (c) the
date and time they became the responsible pharmacist; (d) the date and time they ceased to be; and (e) in
relation to any absence from the premises on a day they were the responsible pharmacist — the date of the absence,
the time it commenced, and the time they returned. Reg 5(2): kept in writing, electronically, or both. Reg 5(3):
available at the premises for inspection by the person carrying on the business, the superintendent (if any), the
responsible pharmacist, and pharmacy staff. Reg 5(4): preserved for not less than five years. Reg 3: the maximum
absence is two hours during business hours, with contactability arrangements, and general-sale-list sales may
continue during the absence.
Finding 1 — the guide overstated the law. The Coverage Plan says "A pharmacy cannot lawfully operate without a
Responsible Pharmacist on duty at the premises." The authority permits absence of up to two hours in the day with
arrangements in place, and allows GSL sales to continue during it. The guide must state the requirement accurately or
not at all. The other two statements (the A1–A3 guide's "the record that shows who was legally responsible", and
Roles and Permissions' "knowing who is legally responsible") are defensible but uncited.
Finding 2 — the app does not hold two of the particulars the regulation requires. The app records the RP's name,
the GPhC registration number, and the signed-on and signed-off date and time — reg 5(1)(a)–(d), all present. There is
no way to record an absence (reg 5(1)(e): date, time commenced, time returned). Nor is there any statement or
control covering the five-year preservation duty (reg 5(4)). The app presents itself as the responsible
pharmacist record, so the gap matters: a pharmacy relying on it alone could not show a compliant record.
Status: awaiting Matthew's ruling. Two decisions: (a) the guide wording — state the two-hour rule and the
inspection and five-year duties accurately, with the source cited; (b) whether the absence particulars and the
retention duty become a build item. Not raised with OpenCode until ruled.
Source found 2026-09-19 (supplied by Matthew): the EPS FHIR API specification, NHS England Digital API
catalogue — https://digital.nhs.uk/developer/api-catalogue/electronic-prescription-service-fhir. The operation set
is embedded in the page and was read from it. This closes the gap: the pharmacy-side operations do exist in EPS,
but our app uses the authority's own words for different operations. That is the defect.
The dispensing operations the authority defines (all POST, all tagged dispensing):
| Endpoint | operationId | The authority's own summary |
|---|---|---|
/FHIR/R4/Task/$release |
release |
"Download prescriptions" — download prescriptions from EPS that are available for dispensing; one by prescription ID, or up to 25 nominated to a dispenser by ODS code |
/FHIR/R4/Task#return |
return |
"Return a downloaded prescription" — return it to EPS "so that another dispensing system can download it"; sent with Task status rejected |
/FHIR/R4/Task#withdraw |
withdraw |
"Withdraw a dispense notification" — for example where a prescription "was marked as dispensed erroneously"; sent with Task status in-progress |
/FHIR/R4/Claim |
send-dispense-claim-message |
"Claim for a dispensed prescription" — "submit or amend a reimbursement claim for a dispensed prescription" |
/FHIR/R4/$process-message#dispense-notification |
send-dispense-notification-message |
"Mark a prescription as dispensed" |
| prescribing side | $prepare, prescription-order, prescription-order-update |
encode/sign and issue prescriptions |
How that lines up with what we built — and where the words collide:
| Our app / our guides | What EPS calls it | Verdict |
|---|---|---|
"Claim a prescription" (guide B1, machine.claim, claim_prescription) |
release — "Download prescriptions" (Task/$release) |
The operation is right; the name is the authority's for something else. What staff do is download a prescription from EPS, and EPS calls that release / download. Our guides call the download a claim. |
"Release claim" (guide B6, machine.release, release_claim) |
return — "Return a downloaded prescription" (Task#return) |
Same collision, reversed. Handing a prescription back is EPS return, and B6's behavioural claim is confirmed by the authority ("so that another dispensing system can download it"). But EPS's release means download, the opposite direction. |
| "Cancel token" (guide B6, label "upstream withdrawal") | not withdraw — EPS withdraw is "withdraw a dispense notification" (undo a dispense marked in error) |
No mapping. Our cancel records a prescription stopped upstream; EPS's withdraw undoes a dispense notification. Our cancel is closer to a prescriber-side cancellation arriving from the spine. |
| "Endorse" + the local reimbursement claim (guide B5) | send-dispense-claim-message (/Claim, "submit or amend a reimbursement claim for a dispensed prescription") |
Phase 2 records this locally and transmits nothing (D3) — correct as a decision, but note the authority's Claim message exists, can be amended, and is the electronic counterpart of the FP34C submission. |
list_dispensable_prescriptions then claim_prescription (two steps) |
Task/$release does both in one call |
App-level design choice, defensible, but it is not the authority's shape. |
Root cause. Same as Finding 1 and Finding 2, now visible at interface level: the mock defined the EPS contract
(eps_client.py: "implemented in the mock, which defines the contract the live API must satisfy"), and its authors
reached for the right-sounding words — claim, release, cancel — without checking that EPS uses those same words
for different operations. The guides then taught the mock's vocabulary as EPS procedure.
Corrective action — NEEDS MATTHEW'S RULING.
release forwithdraw,Silver lining, recorded so it is not lost: the authority confirms the substance of B6 — returning a
downloaded prescription does make it available for another dispensing system to download. The defect is the name and
its direction, not the behaviour.
Raised by Matthew 2026-09-19, from the dispensary screen: the In progress panel has a Claimed column but
no Dispensed column, and he asked whether the claim and the dispense must be recorded as separate events.
Answer from the authority — they are separate events. The EPS FHIR API gives the dispense its own operation:
/FHIR/R4/$process-message#dispense-notification, "Mark a prescription as dispensed" — distinct from
Task/$release (download), /Claim ("Claim for a dispensed prescription"), Task#return and Task#withdraw. And
the dispense date is material to the money: NHSBSA requires claims to be despatched "no later than the fifth day of
the month following that in which the medicine was dispensed", so which month's claim an item belongs to depends on
when it was dispensed.
What the app does — the record is correct. Claim and dispense are stored as separate events with separate actors
and timestamps: claimed_by/claimed_at written by machine.claim, dispensed_by/dispensed_at written by
machine.dispense (onto the endorsement record), and every transition also appends a history row with its own actor,
time and reason. Dispensing history shows both columns (history.html: Claimed = claimed_at, Dispensed =
endorsement.dispensed_at). So there is no record-keeping gap.
The screen gap. queue.html's In progress table is Token / Patient / Status / Claimed / Open, and renders
{{ token.claimed_at }} unconditionally — so a row whose state reads Dispensed shows the claim date, and the
dispense time appears nowhere on that panel. A reader naturally takes that date to be the dispense date.
Proposed (awaiting Matthew's ruling): add a Dispensed column to the In progress panel, or label the existing
one unambiguously. Small UI change; not raised with OpenCode.
The bigger finding in the same area. The app sends no EPS dispense notification at all — there is no
dispense-notification support anywhere in the code (grep finds no reference outside migrations). Our Dispensed
state is therefore purely local. This is covered by the settled D3 decision (endorsement recorded locally: "no
transmission, no claim file, no reimbursement posting"), so it is a known deferral, not a surprise — but it is the
second place where a local record stands in for a required EPS message (the first being /Claim), and both must
be built, in order, for a live EPS deployment: download (Task/$release) → dispense notification → claim
(/Claim) → return / withdraw as needed. Recorded here so it is not discovered late.
Effect on the guides: none today — B4 and B7 describe the app's own behaviour, which is accurate. But no guide may
imply that EPS has been notified of a dispense (it has not), and B4's "the collection notification is queued" is the
patient notification, which is a separate thing again.
Finding. The professional guidance requires that medicines received are of the quantity and quality
specified and suitable for the purpose intended, with attention to product integrity — cold chain and other
storage requirements (3.13) — and requires that concerns are raised when medicines are suspected not fit for
purpose (GPhC standards 4.4). The app records only what was received: there is no field for a short or over
quantity, a damaged or rejected item, a cold-chain breach, or anything held aside pending a decision.
Evidence. GoodsInReceipt carries supplier, reference, warehouse, received-by, status, ERPNext entry and error
— nothing about a discrepancy; GoodsInReceiptLine carries item, quantity, batch and expiry only; StockMovement.Kind
is RECEIPT, CONSUMPTION, ADJUSTMENT — there is no rejection or quarantine movement; and there is no purchase
order in the app, so "the quantity specified" can only be checked against paperwork the pharmacy holds.
Consequence. The check happens — staff compare the delivery to the order, as they must — but the evidence of
what was queried or refused cannot live in the system of record. A pharmacy relying on the app alone cannot show
that it queried a delivery or set stock aside.
Corrective action — awaiting Matthew's ruling. Either add a discrepancy to the receipt (a line-level short /
damaged / rejected quantity with a reason, and a movement kind for stock held aside), or record it as a known
limitation. Guide C1 documents it as a limitation today:
handle it outside the app, set the goods aside securely, and raise the concern the same day.
Finding. The goods-in form chooses a supplier from the pharmacy's active suppliers, but nothing in the
product can create one. Asked how a supplier is added, the answer is: it cannot be, from the app — and not from the
Django admin either.
Evidence.
pharmacy_stock/urls.py has no supplier route, and views.py/forms.py hold no supplier screen or form —Supplier is only ever read (GoodsInReceiptForm filters Supplier.objects.filter(is_active=True)).pharmacy_stock/admin.py registers StockItem, SnomedItemMapping, StockAlertThreshold, StockPositionCacheStockSettings — Supplier is not registered, so the Django admin (which is routed) cannot create oneSynthetic Wholesaler (DEMO), came from the seed command.Why it matters. GPhC standards 4.3 require medicines to be obtained from a reputable source, and the goods-in
record names the supplier as the record of where the medicines came from. A pharmacy changes wholesalers, adds a
short-line supplier and uses a local supplier for unlicensed items; with no way to maintain the list, a receipt
cannot be recorded against the real supplier, or the list has to be changed outside the application.
Corrective action — recorded, not raised. A supplier screen (list + add/edit, stock-managing roles, audit event)
is the obvious fix; registering Supplier in the admin is not a client-usable answer. Not sent to OpenCode — the
build queue is Matthew's to rule on. Guide C1 now tells staff to ask whoever looks after stock if the supplier is
missing.
Finding. The expiry report (guide C3) tells staff which batches have expired or are nearly there, and the guide
then has to say what to do about it — and the only answer the product gives is a general stock adjustment with a
free-text reason. There is no route for expired stock as such: no quarantine state, no disposal record, no
"expired" reason of its own, and therefore no evidence that a pack left the ledger because its date had gone
rather than for some other write-off reason. Set-aside and disposal happen outside the system — the same shape as
Finding 6's delivery discrepancy.
Evidence.
AdjustmentForm (S6) is stock_item, pool, direction, quantity, batch_no (optional), expiry_datereason (free text, any content). StockMovement.Kind is RECEIPT / CONSUMPTION /ADJUSTMENT, so a disposal is an ADJUSTMENT like any other.expiry_report.html renders the list with no action of any kind — no line action, no bulk selection, nopharmacy_stock/urls.py has no expiry, disposal or quarantine route.fefo.select_batches) simply skips an expired batch, so the pack sits in ERPNext, and in every cachedWhy it matters. The duties the guides cite for this screen are exactly the ones a disposal record would evidence
— controlled storage and stock monitoring (3.14), services managed and delivered safely (GPhC 4.2), medicines stored
securely and safeguarded (4.3). A pharmacy working from the app alone can show that it found expired stock (the
report) but not that it removed it as expired, nor that it was segregated pending disposal. It also distorts the
commercial figures for as long as the adjustment is unrecorded.
Corrective action — recorded, not raised. Matthew set the queue on 2026-09-21 while reading C3: supply
ordering first, and the expired-stock work (with the inbound delivery-file import he has researched on
expired_stock) after it. Guide C3 documents the limitation
as it stands — find it, do not supply it, record the adjustment, set it aside — and the adjustment's own retry
hazard is already with OpenCode. Nothing sent to OpenCode as a build ask.
Finding. Both hand-typed date fields — the goods-in line's Expiry (C1) and the adjustment form's Expiry
date (C4) — are plain text boxes parsed with the app's en-us format list. A UK user typing the UK convention
(DD/MM/YYYY) silently gets a different date whenever the day is 12 or under, and an outright refusal whenever it
is 13 or more. Verified live on the client tenant 2026-09-21 by building the form in manage.py shell and calling
is_valid() — nothing was saved:
| Typed | What a UK user means | What the app stores |
|---|---|---|
04/03/2027 |
4 March 2027 | 2027-04-03 (3 April) |
03/04/2027 |
3 April 2027 | 2027-03-04 (4 March) |
09/02/2027 |
9 February 2027 | 2027-09-02 (2 September) |
02/09/2027 |
2 September 2027 | 2027-02-09 (9 February) |
25/12/2027 |
25 December 2027 | refused — "Enter a valid date." |
Accepted forms: %Y-%m-%d and %m/%d/%Y (plus month-name forms such as 25 Dec 2027).
Evidence. dsp_clinic/settings.py sets LANGUAGE_CODE = 'en-us', which produces that DATE_INPUT_FORMATS list;
pharmacy_stock/forms.py declares both fields as forms.DateField() — the default TextInput widget, so there is no
date picker and no format hint on screen. GoodsInReceiptLineForm.clean_expiry_date then compares the parsed date with
date.today(), so a mis-read date can also pass or fail 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).
Why it matters. The expiry date on a batch is exactly what FEFO's exclusion, the 90/30-day bands and the expiry
report key off. A date recorded three months out is invisible until it matters, and this is a regulated stock record
that the pharmacy relies on for the "not expired" decision.
Corrective action — awaiting Matthew's ruling. The fix is to accept UK dates (or use a date input with a picker)
and to state the expected format on the form. Guide C4 currently instructs staff to type YYYY-MM-DD or a month
name and never DD/MM/YYYY — a workaround, not a fix. C1's published guide has the same gap: it tells staff to
enter the expiry from the packaging without naming the format it must be typed in. Not sent to OpenCode until ruled.
Finding. The adjustment template renders a field error for Stock item, Quantity and Reason — but not
for Pool, Direction, Batch no or Expiry date. The one field that can fail validation without a visible
consequence is therefore the one whose error is never shown: the form returns with your values still in the boxes, the
field carries the accessibility attributes for an error, and a sighted user is told nothing about why the adjustment
did not post.
Evidence. pharmacy_stock/templates/pharmacy_stock/adjustment_form.html has {% for e in form.…errors %} spans
for those three fields only. Captured live 2026-09-21: a POST with quantity=0 and expiry_date=25/12/2027 returned
the form carrying the quantity message — "Ensure this value is greater than or equal to 0.01." — and the expiry input
with aria-invalid="true" and aria-describedby="id_expiry_date_error", while no such element exists anywhere in
the page (the error text appears nowhere in the response). StockMovement counts before and after the attempt were
identical: nothing was posted.
Why it matters. It turns a refusal into a silent failure. Staff cannot tell whether the adjustment posted, so they
retry or abandon it — and in a stock record "refused" and "posted" are the difference between no movement and a
movement. Together with Finding 9 it is the same field, twice: the date the app cannot read is also the date it will
not explain.
Corrective action — awaiting Matthew's ruling (one build decision covers both findings: the date field, how it is
read and how its refusal is shown). Not sent to OpenCode.
professional_registration_number, gphc_premises_number, ods_code).A guide may name a regulatory artefact — a code, a certificate, a form, an identifier, a category, a duty — only
when (a) the app demonstrably implements it and (b) it is traceable to an authoritative source (Drug Tariff,
NHSBSA, GPhC, the legislation), with the source and version recorded. Where the authority is the source of a
control, the app follows the authority rather than inventing its own version. Anything that exists only inside the
app is described as a local record, never as an NHS requirement.
All 24 dental guide pages have been dumped and swept with the same method (FP17, UDA, GDC, CQC, NICE, NHS contract,
charge/exemption, statutory). Eleven pages make claims of this class, densest first:
Next: verify each flagged claim against the dental app's code and the authority (NHS dental contract / FP17
guidance, NICE recall intervals, GDC record-keeping standards), exactly as Finding 2 and Finding 3 were verified. This is a
multi-session piece of work and will be reported page by page rather than in one pass.