Status: AUTHORISED by Matthew 2026-09-14, with all six decisions SETTLED and the vocabulary (D5.6) RATIFIED the same day ("all as recommended"). Phase 5 is GO — its gate, Phase 4, was ACCEPTED 2026-09-14 (origin/dev = eb1ce70, 183 tests green, verified against the artifacts). Gate G5.1 is closed (2026-09-15). BUILT, DEPLOYED and ACCEPTED 2026-09-15 — verified against the artifacts, not the build report (see "As delivered" below). This page follows the shape of
Phase 2,
Phase 3 and
Phase 4.
Sequencing note, stated up front. Phase 4's counter (C1–C13) is built, deployed and accepted
(2026-09-14): its gates G4.1/G4.2 were closed first, then the counter itself was verified against the
artifacts. Phase 5 is therefore a build order, not a plan. Phase 6 stays gated on Phase 5 being
verified.
Phase 5 of the agreed plan in module-map — its module
6 row (patient notification: collection-ready alerts via NHS Notify, mock until DSPT) and module 8
(the pharmacy collection flow in the patient PWA).
In:
DISPENSED (Phase 2's ratified state), theOut (deliberately):
This phase carries contact data, which is patient-identifiable, and it hands a message to a third
party — so the boundary matters more here than anywhere in the programme so far.
| Data | Where it lives |
|---|---|
| Patient contact details (mobile, email) and preferences | The client tenant's database, in the client-tier partition. Never central |
| The notification record (template, channel, outcome, timestamps) | The client tier, alongside the token |
| The message content sent to NHS Notify | The minimum: see D5.2. No medication names, no clinical detail, no NHS number |
| The collection record | Client tier, on the token |
| Anything at all in the central database | Nothing |
A notification app exists, but it is appointment-shaped. notifications/models.py holds
ReminderLog (channel SMS/EMAIL, status PENDING/SENT/FAILED/CANCELLED, cancellation tokens) and
notifications/services/reminder_service.py sends them; appointments carries an nhs_notify_id
and a message model. The pattern — a logged, cancellable, status-bearing message with a channel —
is exactly what this phase needs; the shape is an appointment reminder, and a prescription is not
one.
The NHS Notify client — G5.1 CLOSED 2026-09-15: the surface exists, but it is a skeleton. A Notify
client does exist: services/nhs_notify.py — NHSNotifyService(api_key) with one method,
send_notification(nhs_number, message_text, plan_type). The original 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. They are two different services with two different
APIs — do not conflate them. What is actually there is a skeleton, not an integration:
00000000-…-0001/0002/0003 forpersonalisation.body, which the real service does not accept — templates must beX-Request-ID (which Notify requires), no retry, no status polling, no timeout and no errorUSE_MOCK_EPS (default true) behind a factory; Notify pointsIts only callers are appointment-shaped, inherited from the fork (dashboards/views/dashboards.py, a
receptionist message view; 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.
There is also a stray tests/test_nhs_notify.py that is a probe script, not a test — inert, because
the documented runner does not collect that directory.
Two carry-overs for the build. Credential handling is clean: settings.NHS_API_KEY is env-driven
and settings refuses to start without it, so nothing is hardcoded. But the inherited audit pattern
stores message content — AuditLogger.log_nhs_notify_message keeps a message preview and part of
the API response — which is against N9. Phase 5 must not copy it.
The patient portal exists (patient_portal/, with a push service and views that already reference
pharmacy verification), so the PWA-side flow joins an existing app rather than creating one — but there
is no pharmacy collection surface in it yet.
Phase 2 gave the hook: DISPENSED is a state transition with an actor and a timestamp, and
DISPENSED is never re-opened. A notification trigger attaches there, and a collection record appends
after it — no new state-machine vocabulary is needed for the trigger, only for the handover.
Phase 4 (when built) gives the counter's NHS charge collection, which is the other half of what
happens at the counter on collection day.
agents/handoff/from-hermes.origin/dev = eb1ce70, 183 tests green).| # | Work item | Owner | Indicative size |
|---|---|---|---|
| N0 | Gates G5.1–G5.5 closed and recorded | OpenCode (G5.1), Hermes (G5.4), Matthew (D5.x) | S |
| N1 | The notification app for prescriptions on the client tier — a client-tier app (or a pharmacy-shaped extension) holding contact preference, the send record and the collection record. No clinical content | OpenCode | M |
| N2 | The NHS Notify integration — mock-first behind one switch, as EPS is; templates parameterised; failure surfaced with the provider's message; retry policy explicit | OpenCode | M–L |
| N3 | The trigger — a notification is queued when a token reaches DISPENSED, and never before; a token that is RELEASED or CANCELLED never sends |
OpenCode | S–M |
| N4 | Handover — recording that a patient collected (or that stock was returned to the shelf), appended to the token, with the actor; DISPENSED stays immutable |
OpenCode | M |
| N5 | Opt-out and preference — captured, honoured on every send, visible to the pharmacist, and never silently overridden | OpenCode | S–M |
| N6 | The delivery trail and the "did they know?" view — per token: what was sent, when, on what channel, and the outcome; plus the uncollected list with age | OpenCode | M |
| N7 | The PWA collection view — "ready to collect", pharmacy and opening hours, nothing clinical; authentication via the patient's existing portal identity | OpenCode | M |
| N8 | Role gating — who may send, resend, mark collected, change a preference or see contact details; refusals tested at the view layer for every role | OpenCode | S–M |
| N9 | Audit and DSPT evidence — send, failure, opt-out and collection events with actor and time; no contact details or clinical content in the audit trail | OpenCode | S |
| N10 | Synthetic seed — fake patients with obviously fake mobiles/emails (e.g. 07700 900xxx and example.test), a dispensed-not-collected token, an opted-out patient, a failed send |
OpenCode | S–M |
| N11 | Tests and the documented runner — trigger correctness including the never-sends cases, opt-out honoured, mock Notify behaviour on failure, collection append-only, RBAC refusals, and that contact data exists only on the client tier | OpenCode | M |
| N12 | Report back, then verification, walkthrough and the guides | OpenCode then Hermes | S + S |
Matthew settled all six on 2026-09-14, every one as recommended. Recorded as settled with the reasoning kept, as Phases 2–4 were — the reasoning is what a later reader needs when a trade-off is revisited.
| # | Decision | Settled |
|---|---|---|
| D5.1 | What the notification is for — collection-ready only, or also a reminder if it is not collected | SETTLED — Both, with the reminder as a separate template and an explicit age threshold. Uncollected dispensed stock is a real operational problem, and a single "ready" message leaves it invisible |
| D5.2 | What the message may contain — the minimum ("your prescription is ready"), or the medication names too | SETTLED — The minimum. NHS Notify is a third party; a message naming medicines discloses health data to it and to whoever holds the phone. Name, pharmacy, opening hours, a reference the patient recognises is enough for the patient to act |
| D5.3 | Where contact details come from — captured at the counter/dispensary, imported from the patient portal, or entered at dispensing | SETTLED — Captured by the pharmacy and held client-side, with the portal as a later channel rather than the source. The dispensing token has no contact details and the pharmacy already has the patient in front of it; importing identities would be a bigger data-protection question than the feature is worth |
| D5.4 | Is the collection recorded by the pharmacy, or by the patient | SETTLED — By the pharmacy, as part of handling the script across the counter — a patient self-certifying collection is not a record a pharmacy can rely on |
| D5.5 | What happens to uncollected stock | SETTLED — Flagged and reported, never automatic. After the configured period it appears on the uncollected list and stays the pharmacist's decision whether to return it to stock (an adjustment, by the ratified Phase 3 vocabulary) |
| D5.6 | Vocabulary — the words for this phase | SETTLED — collection-ready (never "ready for pickup"), handed over as the ratified Phase 2 term for the act, collected as its record, uncollected with an age, preference for channel and opt-out |
Phase 5 is done when, against the mock Notify and with synthetic data:
DISPENSED queues exactly one collection-ready notification, and a token that isDISPENSED record is never edited;Built as the client-tier app pharmacy_notifications (origin/dev = d77621f, three commits:
the app, the dispensing hook, the patient-portal view). Deployed to the app engine; the entrypoint applied
pharmacy.0011 + pharmacy_notifications.0001, migrate_client applied both to test-client-pharmacy.
| # | Acceptance criterion | Evidence |
|---|---|---|
| 1 | One collection-ready per DISPENSED; RELEASED/CANCELLED queue none |
The trigger exists only in pharmacy_dispensing/machine.py::_queue_notification_best_effort, called from dispense. Live: queue_collection_ready on the CANCELLED, ASSEMBLED and CHECKED demo tokens all returned None and created no row |
| 2 | An opted-out patient is never messaged; the opt-out is visible and reversible | Seed: demo-collection-optedout-1 → status OPTED_OUT, 0 attempts (nothing was handed to the provider). Honoured at both queue and send time |
| 3 | A send failure is visible and the token still shows as ready | Seed: demo-collection-failed-1 → FAILED, reason mock failed for 07700 900999 |
| 4 | Collection is append-only and DISPENSED is never edited |
Live drive: record_collection wrote a CollectionRecord with the actor's name snapshot (Testford Pharmington); the token's status was still DISPENSED afterwards; delete() raised NotImplementedError |
| 5 | The trail answers "did they know?" without exposing contact details | delivery_trail returned the row; the audit detail is Sent via SMS — channel only, never the address |
| 6 | The PWA shows collection state to the right patient and no one else | patient_portal PatientCollectionViewSet is read-only, permission-gated, and scoped to the authenticated patient's NHS number; client-tier only (the apex returns nothing, documented) |
| 7 | The message complies with D5.2 | Live payload: {patient_name, pharmacy_name, pharmacy_telephone, pharmacy_address, reference} — no medication names, no clinical detail. Asserted against the token's own items |
| 8 | Suite green, and nothing central | 226 tests, OK on my own run; dsp_clinic (central) holds 0 pharmacy% tables; the apex veripath_pharmacy and the client database each carry the three new tables with pharmacy.0011 + pharmacy_notifications.0001 recorded |
All five surfaces (/pharmacy/, /dispensary/, /stock/, /counter/, the new /collection/) return
302 → OIDC on both the apex and the client host. Synthetic data only (07700 900xxx / example.test).
Where the spec met reality differently (accepted as delivered):
PharmacyPremises, so the message carries the pharmacy's name,PrescriptionNotificationattempts and updates the status rather thanFAILED, reason "No contact detailsservices/nhs_notify.py skeleton and its appointment-shaped callers are left untouched;