Status: SPECIFICATION — AUTHORISED by Matthew 2026-09-14; decisions D1–D6 settled 2026-09-14; the
pre-work gates are RATIFIED 2026-09-14 (terminology, the state machine, and the mock EPS surface
shape — see the gates section); the roles-and-permissions guide is
inside Phase 2 (Matthew, 2026-09-14). W0–W9 are built, verified and DEPLOYED (origin/dev =
1634804); all five verification findings from 2026-09-14 are fixed and migration 0002 is applied
on both client-tier instances — see the "As delivered" sections below and the follow-up note there.
Phase 2 of the agreed
plan in module-map. Phases 0 and 1 are complete
and verified; the first client-tier tenant exists and its data now sits on the client's own box
(test-client,
client-vps). OpenCode builds this; Hermes
specifies it, verifies it against the artifacts, and documents it afterwards.
Phase 1 established who may act — identity, roles, premises identity, and the legally required
Responsible Pharmacist Log. Nothing in it moves a medicine. Phase 2 is where the app stops being a
login wrapper: a prescription arrives, someone claims it for the pharmacy, it is assembled, a
pharmacist checks it, it is dispensed, and the claim for reimbursement is endorsed. Every later phase
hangs off this: stock exists to feed dispensing, the counter exists to sell alongside it, ERPNext
exists to account for it, and the notifications exist to tell the patient it is ready.
Sequencing rationale (from the module map, unchanged): authentication and the RP log were the
legally blocking pieces; dispensing is the operational core; compliance work (audit, encryption,
RBAC at the view layer, DSPT evidence) is built into the phase, not bolted on afterwards.
In:
Explicitly out — do not build ahead:
The terminology section of the module map separates dispensed (the clinical act is complete) from
handed over (the patient physically collected it), and the delivery table puts collection in
Phase 5. Phase 2 therefore ends at dispensed and endorsed, and the app must treat
"dispensed, not yet handed over" as a legitimate resting state — not an error and not a prompt to
finish something. Do not add a handover step, a collection timestamp or a patient-notification hook
to this phase; adding them later is a state appended to the machine, which is why the machine is
specified properly now.
We hold client-organisation data only. No patient-related data is held by us at any tier, ever.
| Data | Where it lives | Must never be |
|---|---|---|
| Dispensing records — claimed token, items, quantities, state history, endorsement data | Client tier only, in the pharmacy-partition app (see W2) | Central, in any form — not even a count or a status summary |
| Patient identifiers (name, NHS number, address, DOB) as they appear on a dispensed item | Client tier only; the client's own database is their legitimate home | Central, in logs, in audit rows, in error messages, in Celery payloads visible centrally |
| Pharmacist who performed each step (name snapshot, personal GPhC number) | Client tier, on the record itself | Central |
| Org identifiers — ODS code, GPhC premises number, tenant/subdomain | Central, on the existing tenancy.Tenant |
— (already the case, Phase 1) |
| Audit trail of dispensing actions | Client tier, with no clinical or patient content — actor, action, target, time | Central |
The central tier stays a stateless router. This is a DSPT selling point and nothing in Phase 2 may
weaken it.
| Reusable | Detail |
|---|---|
| Sector router + partitions | tenancy/routers.py: PHARMACY_APPS, and a data-driven SECTOR_APP_PARTITIONS, so a new operational module is a one-line registration plus its models — no new routing branch |
| Client-tier routing | A client-sector tenant's rows route to that client's own database alias; already exercised for this sector |
| Audit + append-only pattern | pharmacy/models.py: PharmacyAuditEvent + record_audit(); the RP log's model delete() raises rather than deleting. The partial-unique-index pattern (one active RP per premises) is the precedent for database-enforced invariants rather than clean() checks |
| Payment-config pattern, per-tenant | Exists for later phases; not used in Phase 2 |
| Mock/data separation | USE_MOCK_EPS (default True) and get_eps_client() factory in clinical_data/integrations/eps_client.py |
The inherited EPS integration was built for the GP app issuing prescriptions. Its whole surface is
prescribing-side:
MockEPSFhirClient / EPSFhirClient: issue_prescription(), check_status(),cancel_prescription(), search_prescriptions() — and the mock's clear_mock_data().So "pull/claim → dispense" is not a wiring job: Phase 2 must design and add a pharmacy-side EPS
surface (W3). That is the single largest hidden piece of this phase, and it is better discovered
here than after someone has quoted the work as "reuse the EPS client".
Related: clinical_data's models (Prescription, PatientMedication, PatientAllergy, …) sit in
the router's GP_APPS partition, which routes to sector_gp. They must not be reused for
pharmacy any more than the tables may be shared — pharmacy dispensing models need their own app in
the pharmacy partition and their own client-tier tables.
AESClient.sign_prescription() signs a prescription document — it takes patient, medication and
pharmacy fields and returns a signed PDF plus its SHA-256. It is a document-signing call, not a
transition-signing call. The module map's phrasing "AES-signed transitions" therefore needs a
decision, not an assumption (D1). The client also offers list_pharmacies(),
download_prescription_pdf(), download_prescription_evidence() and verify_connectivity().
| # | Work item | Owner | Indicative size |
|---|---|---|---|
| W0 | Routing guard — alias cache with an invalidation path, or a startup assertion comparing each client alias's host/port/dbname against its tenant row, logging loudly on divergence; and a client tenant with no per-client alias must fail loudly rather than fall back to the central database | OpenCode | S–M |
| W1 | Terminology ratification — ratify the module map's five terms, plus the new ones this phase introduces (claim vs pull; endorse as the reimbursement-claim event) | Matthew | XS |
| W2 | Module app + models on the client tier — a new app (suggested pharmacy_dispensing) registered in PHARMACY_APPS and INSTALLED_APPS, with: the claimed prescription token, dispensing item rows, the state history, the endorsement record, and audit. Migrations must route to the client tier and be verified there |
OpenCode | M |
| W3 | Pharmacy-side EPS surface in the mock — claim/release/return/token-status operations added behind get_eps_client(), deterministic, no network, shaped like the real API's resources so the live switch is one module |
OpenCode | M–L |
| W4 | The state machine — explicit states and permitted transitions with guards: a claim is held by one pharmacy; assembly cannot precede claim; the accuracy check requires a Responsible Pharmacist (the checker may be the assembler — D6) and dispense requires the check; endorsement requires dispense and a pharmacist; a cancelled token releases the claim; every transition writes the actor, the time and the before/after state | OpenCode | M |
| W5 | Screens — queue of claimable tokens; claim; assemble (items and quantities supplied); the pharmacist's accuracy check; dispense; endorse; and a pharmacy dispensing history. Plain, consistent with the existing pharmacy templates | OpenCode | M–L |
| W6 | Role gating at the view/queryset layer — D6 settled: the Responsible Pharmacist claims, assembles, records the accuracy check, dispenses and endorses; the Dispensing Technician claims and assembles but cannot check or endorse; the Counter Assistant reaches no prescription surface; the Business Manager reaches no clinical surface unless separately registered as a pharmacist. The record stores both the assembler and the checker. Enforce with mixins on every view and querysets scoped to the tenant — navigation is not a control | OpenCode | S–M |
| W7 | Audit and DSPT evidence — every state change recorded with actor, action, target, timestamp, and no clinical or patient content in the trail | OpenCode | S |
| W8 | Synthetic demo seed — synthetic patients with obviously fake identifiers, a handful of tokens in different states so every screen has something real to show, idempotent and safe to re-run. This is a deliverable: it is what the walkthrough and the user guides are built from | OpenCode | S–M |
| W9 | Tests and the documented runner — model/state-machine tests (every guard, including the ones that must refuse), view-level RBAC tests for all four roles, and mock-EPS tests. Run with the documented runner (Phase 1 finding 3: a fresh database still cannot be built from migrations, so the test settings module builds the schema from the models) | OpenCode | M |
| W10 | Report back — file pointers for the doc pass, plus every place this spec met reality differently | OpenCode | XS |
| W11 | Verification — Hermes verifies each work item against the artifacts (repository, live site, client database), not from the build report; then the live walkthrough; then the user guides one page at a time | Hermes | — |
| W12 | Roles and permissions guide — the third and last Phase 1 guide, moved into Phase 2 by Matthew (2026-09-14) rather than left to close the Phase 1 set. Written after W6 lands, so it documents the real gating (Phase 2 separates assembly from the pharmacist's accuracy check, which the held version would have got wrong), one page at a time for review, and published like the other two | Hermes | S |
Matthew answered all six on 2026-09-14. They are recorded as settled, with the reasoning kept —
that reasoning is what a later reader needs when a trade-off is revisited.
D1 — Cryptographic signing in Phase 2: DEFERRED (Matthew: "not sure, so we'll defer"). Phase 2
builds no cryptographic signing. Each transition is recorded with authenticated identity, an
immutable audit row and a name snapshot. Revisit as a DSPT non-repudiation item — alongside the same
question for the RP log, deferred in Phase 1. If it is revisited, the honest scope is "render the
endorsement artefact as a PDF and sign that through the AES portal": a work item in its own right,
because the AES client signs documents, not state changes.
D2 — The NHS prescription charge: DECIDED (as recommended). Phase 2 records the declaration —
charge due, or the exemption category with its evidence flag — as data at dispense time, and takes
no payment. Sequence matters: the declaration is captured while the pharmacist is looking at the
script; the money is a Phase 4 counter event. The "explicitly out" list above still holds for
payment.
D3 — What "endorse" does: DECIDED (as recommended). Phase 2 records the endorsement locally
and stops there: the data an NHSBSA submission will need, captured at the moment it is known. No
transmission, no claim file, no reimbursement posting (Phase 6).
D4 — Repeat dispensing / eRD: DECIDED (as recommended). Stays on the deferred list. The token
model must not preclude it: a token belongs to one issue and a repeat series is a later parent-shaped
entity, so the model needs no special case now — but it must not assume "one token = one-off".
D5 — Medication history and allergies: DECIDED (as recommended). Out of Phase 2. It is
clinical-safety scope — legitimate on the client box, but extra surface. It becomes a named later
item rather than an implied part of dispensing; Phase 2 shows what is on the script being dispensed.
D6 — Who may perform the accuracy check: DECIDED (as recommended). Only a Responsible
Pharmacist may record the accuracy check, and only a pharmacist may endorse. Claiming and assembly
are open to the Dispensing Technician. The checker need not be a different person from the
assembler — in a small pharmacy that is often impossible — but the record must carry both actors,
so "who assembled versus who checked" is always answerable from the data. Counter Assistant: no
prescription surface at all. Business Manager: no clinical surface unless separately registered as a
pharmacist.
The three remaining pre-work gates — terminology, the state machine, and the mock EPS surface shape —
are unaffected by these six decisions and were ratified by Matthew on 2026-09-14; they are recorded
below.
All three gates that were holding W4 and W5 are ratified, so this section is now the contract for
the state machine and the vocabulary. The ratified text is below, unchanged from the drafts Matthew
approved ("all as drafted", 2026-09-14). One consequence to carry: because migration 0001_initial
is already recorded as applied (apex and client databases), a later rename of this vocabulary needs a
new migration rather than an edit to 0001.
The module map's five terms, plus two this phase introduces. Pinning them matters because in Phase 2
they stop being words and become model fields, states and screen labels.
| Term | Meaning to pin |
|---|---|
| NHS prescription charge | The statutory band charge. Never "price", never "payment" |
| Retail OTC VAT | Standard-rated, versus the zero-rated NHS dispensation — different tax basis, different ledger treatment |
| Endorsed | The reimbursement claim data recorded and complete. Locally only in Phase 2 (D3) — it does not mean transmitted |
| Assembly vs accuracy check | Two separate states with separate audit entries. Picking into a basket is not the pharmacist's check |
| Dispensed vs handed over | Dispensed = the clinical act is complete. Handed over = physically collected. Phase 2 stops at dispensed, and "dispensed, not collected" is a normal resting state |
| claim vs pull (new) | "Claim" is the state; "pull" is deliberately not used anywhere in the model — one word, not two |
| dispensary vs counter (new) | Dispensary = clinical work (Phase 2); counter = sales (Phase 4). Keeps screen names from colliding later |
States: AVAILABLE → CLAIMED → ASSEMBLED → CHECKED → DISPENSED → ENDORSED.
Side states: RELEASED (the claim is abandoned and the token returns to AVAILABLE) and
CANCELLED (withdrawn upstream).
| Transition | From | Guard |
|---|---|---|
| claim | AVAILABLE |
The token is not held by another pharmacy. Actor: RP or Dispensing Technician |
| assemble | CLAIMED |
Records the items and quantities supplied. Actor: RP or Dispensing Technician |
| accuracy check | ASSEMBLED |
RP only. Records the checker separately from the assembler |
| dispense | CHECKED |
Records the charge declaration (charge due, or exemption category + evidence flag). Actor: RP |
| endorse | DISPENSED |
RP only. Local record; no transmission (D3) |
| release | CLAIMED or ASSEMBLED |
Returns the token to AVAILABLE, recording who and why |
| cancel | any pre-DISPENSED state |
Upstream withdrawal. Never from DISPENSED — see below |
Every transition writes the actor, the timestamp, the before and after state, and an audit row with
no clinical or patient content.
Three calls inside it, all recommended: the same person may assemble and check (both actors are
recorded, so it is always answerable — D6); endorse is RP-only; and DISPENSED is never
re-opened — the dispensing happened, so a correction is a new appended record, exactly as the RP log
treats its own history.
The five operations W3 shipped behind get_eps_client() are ratified as the Phase 2 contract the W5
screens build against: list_dispensable_prescriptions, claim_prescription, release_claim,
return_prescription, get_pharmacy_prescription_status. They are deterministic, network-free, and
present in both clients — the real EPSFhirClient implements the same signatures and raises
NotImplementedError with a DSPT-gating message rather than pretending the live surface works.
One value stays provisional by decision: the base path (EPS_DISPENSING_BASE_PATH, default
/dispensing, flagged in code). It is a settings value, so it is settled at the live switch rather
than treated as a code change. Ratifying the operation set closes the shape gate; it does not close
the DSPT gate that unlocks the real endpoint.
Phase 2 is done when, on the live client host and against the mock EPS:
OpenCode's session delivered the three unblocked work items on branch feature/pharmacy-phase2-dispensing
(three commits: 2d9f64e W0, 5f36292 W2, 2816aed W3). Verified against the artifacts, not the build
report:
| Checked | Evidence |
|---|---|
| W0 — the routing guard | tenancy/routers.py: the load-once boolean is replaced by a TTL cache (CLIENT_ALIAS_CACHE_TTL, default 30s), a public invalidate_client_aliases(), divergence detection that closes stale pooled connections, and a new ClientDatabaseUnavailable raised instead of falling back to the shared/central alias. seed_pharmacy_sector invalidates on a client retarget; migrate_client force-reloads. Three assertRaises(ClientDatabaseUnavailable) tests, and the guard's own log lines fire during the suite run ("refusing to fall back to the shared/central database") |
| W2 — the module app | New pharmacy_dispensing app in INSTALLED_APPS and as a one-line addition to PHARMACY_APPS — the data-driven partition works as designed. Models: the token (with the patient fields), items, append-only state transitions carrying a per-transition actor, and an endorsement record with the charge declaration and separate dispensed_by / endorsed_by. Plus migration 0001_initial, admin and ~176 lines of tests |
| W3 — the pharmacy-side EPS surface | MockEPSFhirClient gains list_dispensable_prescriptions, claim_prescription, release_claim, return_prescription, get_pharmacy_prescription_status — deterministic and network-free. get_eps_client() documents the prescriber/pharmacy split, and the real EPSFhirClient implements the same signatures but raises NotImplementedError with a DSPT-gating message rather than pretending to work |
| W4/W5 not built | pharmacy_dispensing/states.py is a draft header plus TextChoices, marked "DRAFT — NOT YET RATIFIED … Do not add transition logic against this vocabulary until W4 is authorised". No transitions, no guards, no screens |
| No signing | Nothing in the new code touches AES or signing — D1 deferred, and it stayed deferred |
| Client-tier placement | The four dispensing tables exist only in client-tier databases; nothing central. Migration 0001 is applied to both the apex sector database and the client-owned box's database; all tables empty |
| Test suite | scripts/run_pharmacy_tests.sh — 53 tests, all passing (tenancy, pharmacy, pharmacy_dispensing, pharmacy mock-EPS) |
| Live behaviour | Unchanged, as expected — see finding 1 |
origin/dev is still 983df48; the three commitspharmacy_app container has nopharmacy_dispensing. Live behaviour is therefore unchanged (checked on both pharmacy hosts, both0001_initial has been applied to both the apex0001 is recorded as applied, a rename of0001. Thestates.py therefore holds for the code, notEPS_DISPENSING_BASE_PATH is provisional and flagged as such in the code — it belonged with theOpenCode's second session delivered W4 (state machine), W5 (screens), W6 (role gating), W7 (audit),
W8 (seed) and W9 (tests) in one commit on feature/pharmacy-phase2-dispensing — 9d74e53, 24 files,
~1,910 insertions, on top of the now-pushed 2d9f64e/5f36292/2816aed (origin/dev moved to
2816aed). Verified against the artifacts, not the build report:
| Checked | Evidence |
|---|---|
| W4 — the ratified contract, exactly | pharmacy_dispensing/machine.py is the only place transition logic lives. States, transitions and guards match the ratified table line by line: claim (AVAILABLE, RP/Technician), assemble (CLAIMED, items + quantities), accuracy_check (ASSEMBLED, RP only), dispense (CHECKED, RP, charge declaration), endorse (DISPENSED, RP only, local), release (CLAIMED/ASSEMBLED), cancel (pre-DISPENSED only). Every transition runs select_for_update inside transaction.atomic(using=alias) on the token's own database, then appends a DispensingStateTransition (actor + name snapshot + before/after + time + reason) and a PharmacyAuditEvent |
DISPENSED never re-opened |
TRANSITIONS[DISPENSED] == ('endorse',); cancel's allowed states stop at CHECKED; ENDORSED accepts nothing. Tested (test_dispensed_is_never_reopened, test_cancel_is_never_allowed_from_dispensed) |
| D6 — both actors recorded | assembled_by/at + checked_by/at added by additive migration 0002_dispensingtoken_assembler_checker; the checker is written by the RP-only accuracy_check, so assembler and checker are separately answerable. test_same_person_may_assemble_and_check asserts the allowed case |
| W5 — screens | pharmacy_dispensing/{views,urls,forms}.py + five templates (queue, token detail, assemble, dispense, history), mounted at /dispensary/; nav link in the pharmacy base template |
| W6 — role gating | New PharmacyDispensaryRequiredMixin (RP + Technician) on the dispensary-level views; ResponsiblePharmacistRequiredMixin on check/dispense/endorse; Counter Assistant and Business Manager refused at the view layer; every queryset scoped to the tenant's premises (_tokens(premises)), so a token is not readable by pk across tenants |
| W7 — audit content | record_audit writes action, actor, target type/id and a generic detail string; test_audit_rows_carry_no_patient_content asserts the trail holds no patient content. The pharmacy audit model is local to the app, so it routes to the tenant's database |
| W8 — synthetic seed | seed_pharmacy_dispensing seeds one token per state (Testpatient Alfa…Golf, NHS numbers 9990000001–9990000007 — obviously fake); re-running reported exists (<STATE>) for six tokens, so idempotency is real; --reset removes them |
| W9 — the suite | Independent re-run here: 90 tests, OK under scripts/run_pharmacy_tests.sh. The 47 in pharmacy_dispensing/tests.py cover every guard including the refusals, all four roles under direct URL access, anonymous → login, the append-only history, and the routing of the tables to the client tier |
Migration 0002 is NOT live |
Read directly: the apex sector_pharmacy database has only 0001_initial applied (4 tables); the client-owned box's test_client_pharmacy likewise has only 0001_initial (4 tables, 0 rows), read through the app's own decrypted connection. So OpenCode's claim holds on both instances — no schema-ahead-of-code this time |
| The data boundary holds centrally | The central database (dsp_clinic) contains no pharmacy_dispensing% table and no pharmacy audit table. Data-boundary table still true |
release semantics (OpenCode's W10 flag 2) |
Implemented as I read the ratified text: the token's status returns to AVAILABLE and the appended history row records RELEASED. Both statements in the ratified contract hold at once — confirmed, no change wanted |
_dispensable_store() builds the store once per process as a class attribute andMOCK_EPS_STORE_PATH changes. So a seed run by a management command (a separatedemo-token-available-1 will not appear in the queue until pharmacy_app restarts. The queue is_JsonStore/_dispensable_store, not observed live:transaction.atomic() without using= in ClaimTokenView.post andseed_pharmacy_dispensing._seed_one: the token-plus-items insert is therefore atomic on default,transaction.atomic(using=<alias>) for the same reason.Could not read the dispensable list: {exc},Could not claim the token: {exc}, Could not release the claim: {exc}). A database or connectionrelease talks to EPS before the local guard runs. If the local transition then refuses (or theCLAIMED — aDispensingStateTransition.delete() raisestest_transitions_are_append_only proves it), but PharmacyAuditEvent — described as append-only —USE_MOCK_EPS true (the livemedia/mock_eps_dispensable.json inside the app container, holding the synthetic names and NHS/media/ is wired only under DEBUG, and the liveDEBUG=False) and it is synthetic only — but it is a new on-disk store on the clientTwo of OpenCode's W10 flags are accepted as read: the additive identifier on the mock task (the
ratified operation set is unchanged) and the release status/history split (see the table row above).
Still outstanding: the branch is unpushed (origin/dev = 2816aed, 9d74e53 is a single local
copy) and undeployed, so nothing here is live and live behaviour is unchanged; migration 0002 is
deliberately unapplied on both instances; and the entrypoint/migrate_client step for 0002 is needed
before any deployment.
Verified in commit 09b1c6f ("close out the W4–W9 review findings") and re-checked by reading the code:
USE_MOCK_EPS is true, so a seed no longer needs an app restart and notransaction.atomic(using=db_alias) in both places that lacked it (ClaimTokenView.post and theexc_info=True andmessages.error(request, str(exc)) calls carry guard messagesInvalidTransition, which are meant to be read by the user.)ReleaseTokenView now checks machine.permitted_actions before touching EPS, and re-claimsPharmacyAuditEvent gained the same append-only delete() barrier as DispensingStateTransition.Also closed around it: the DefectDojo findings: 0 parser, the pharmacy tunnel health check (cron now
covers test-client-pharmacy every 15 minutes) and the AGENTS.md pharmacy topology. Migration 0002
is applied on both client-tier instances and W0–W9 are deployed, so the phase is now complete apart
from the W12 roles-and-permissions guide (mine, to follow W6's gating) and the S5 blocker recorded
on the Phase 3 page — the stock consumption hook that Phase 2's dispense calls does not fire for a
prescription claimed through the queue, which is Phase 3's defect to fix but Phase 2's transition to
re-test once it is.