Status: BUILT AND VERIFIED LIVE — 2026-09-26/27. OpenCode's build
8abc4bdis deployed on the app host and
verified live: the real mailbox was ingested (5th update 182 = 182, 6th 216 = 216), the month's
effective set is 216 distinct lines and matches CPE's own page row-for-row, the suite is 136 OK (1 skipped),
and dm+d and the Drug Tariff are untouched (§17). Two decisions since: no request is sent to CPE (§15), and
Matthew's steer of 2026-09-27 — the concession data is to be made available to clients, with the licensing tasks
owned by him; until the register records that basis the display gate stays shut by design (§15, §18).Nothing is outstanding for the acquisition. The alerts already arrive in the existing mailbox, the credentials are
in the app host's environment, and two real messages are retained as the reference artifacts (§16). No request is
sent to CPE (2026-09-26), and the data is to be made available to clients (2026-09-27) — the licensing basis is
Matthew's to record, and until it is recorded the display stays closed by design (§15, §18).Parents: Central Reference Data (the plan, the
decisions, the licence register §5.11) and Data Refresh Schedule
(§4 is this source). Precedent for the shape: The Drug Tariff — Phase 4 Ingestion
(built and verified live 2026-09-26). Store and views already live: Data Feeds — Backend Status View.
This is the third and last of the NHS reimbursement sources — and the one that decides what a pharmacy
is actually paid. dm+d gives the pack structure and the published price (live); the Drug Tariff gives the
deduction rates and the Single Activity Fee (live since 2026-09-26). Price concessions override both for
the month they apply to: when a line cannot be sourced at or below the tariff price, DHSC sets a
concessionary price at CPE's request, contractors are reimbursed at it for that month, and — since
1 April 2023 — the line is discount-not-deducted for the month(s) it is on concession (Part II group
item "Concessionary Price products").
So without this source the reimbursement guardrail is not merely incomplete, it is wrong in the direction
that costs the contractor money: a concessionary line would be assessed on the tariff price and the wrong
discount. It is the last data the guardrail needs, and it is the source the plan has always named as
phase 5.
What the store holds today (checked in the repository, 2026-09-26): the price_concessions source row
already exists — publisher Community Pharmacy England (CPE), distribution route CPE monthly update
posts, licence PSNC/CPE copyright with the condition text "Copying is authorised for personal use only
… no documents or resources may be uploaded to other websites without the prior consent of PSNC", and
consent_required=True — but its routine is NONE ("Not automated yet") and it holds zero
releases. Every mechanism it needs on the day it lands is already built: the release store, the
verify/import/publish flow with retention and rollback, the attempt log, the health surface, the licence
snapshot and the consent gate.
In v1: the mailbox and its configuration · the polling task · the parse of one update email ·
the validation gate and unattended application · the month release in the existing store with every
email retained as evidence · health, cadence and overdue wiring for a stream (not a one-shot document) ·
the platform-admin views (feed row, release detail, per-update list) · attribution · tests · deploy.
Out of v1, deliberately:
/medicines/ view. CPE's terms are recorded asdefault connection.Decided by Matthew (2026-09-26): the data arrives by email alert. CPE publishes each update as a news
post and states on the hub page "Please subscribe to our mailing list to receive email updates of any price
concessions" — the mailing list is the delivery mechanism, and the email body carries the values, with no
attached file.
The mailbox already exists as a capability. veripath.co.uk mail is hosted for us (the domain's MX
record points at our mailbox provider, and the webmail client already running on the platform host is
configured against that provider's IMAP endpoint). So there is no new provider, no third-party parsing
service and no new public endpoint in this design — the message lands in a mailbox we control and the
platform application fetches it. The provider endpoints and the credentials live in the app host's
environment, never in this page.
| # | Step | Owner | What proves it |
|---|---|---|---|
| 1 | Create a dedicated mailbox on that existing service for central reference data, IMAP enabled — one consumer, not a shared human inbox. A mailbox, not a folder: Roundcube creates folders inside an existing account, and a folder is not a separate address. | Matthew (owner) | A test message to it is retrievable over IMAP. |
| 2 | Subscribe that mailbox to CPE's price-concession alerts (the hub page's mailing-list link). | Matthew | CPE's confirmation, then a real message arriving. |
| 3 | Hand the credentials over host-locally for the app host's .env (REFERENCE_DATA_CONCESSIONS_IMAP_*) — a root-only file on the platform host, or you edit the app host's .env directly. Not through the wiki, a note, or a chat session: a chat transcript is retained, and a webmail login is the very secret the application will use. |
Matthew → Hermes | The env group exists on the app host; no secret in the repository, on a wiki page, or in a session log. |
| 4 | One real message is kept as the reference artifact, headers intact — done 2026-09-26 (§16): two messages retained as .eml, read-only from the live mailbox. |
Matthew → Hermes | The fixture is built from its structure (synthetic values); the real body is not committed. |
| 5 | Prove the connection from the app host before any code is built: connect, select the folder, run the scoped search. Done 2026-09-26 (§16) — egress, login and the search all proven from the app host. | OpenCode, Hermes verifies | A live connection returning the alert messages. |
| 6 | Parse and store by hand first (the upload path, §3.4), then wire the 15-minute poll (§7). | OpenCode | The parsed rows against the sample; then an unattended run. |
| 7 | Verify live and record it on this page — the month's release detail, the update list, the raw emails, the reconciliation against CPE's page. | Hermes | The evidence on this page. |
Only steps 1–4 need a human, and none of them blocks the build: see the sequencing in §3.4.
Where the sample message goes — and what was configured on our side (2026-09-26). The parse can be confirmed with
no mailbox at all: a root-only drop directory on the platform host, /root/incoming/cpe-concessions/, takes
one saved .eml (the webmail client's message menu has a download/save-as), and from it the parse anchors, the sender
domain and the subject pattern are checked against the real wrapper, with the fixture built from its structure.
Two changes were made on our side the same day, both recorded rather than assumed:
managesieve plugin was disabled. The mail host runs no Sieve server — theOne clarification this turned up: the webmail client creates folders inside an existing account, not mailboxes
(step 1). So a "folder called CPE-Concessions" is not a separate address; if it is a folder, the poll targets that
folder in the mailbox it belongs to, and the allowlist is what keeps the rest of that mailbox out of the parser.
INBOX — narrower surface, a human's mail can never beINBOX), and the sender allowlist is the control thatUNSEEN from the CPE domain), so anything else in the\Seen and it writes no flags either, because a person reading the alerts inREFERENCE_DATA_CONCESSIONS_IMAP_* group in the.env), not in a database row and never in the wiki. There is one mailbox, it is not tenant.eml is retained for the release alongside its hash, so aWhy not reuse the existing IMAP machinery as it stands. The platform does already poll IMAP
(lab_documents/imap_ingestion.py + PracticeMailbox, scheduled every 5 minutes) and it is a good pattern
to follow — sender filter, MIME decoding, idempotent de-duplication on UID, per-message status. But it is
clinic-scoped: PracticeMailbox hangs off ClinicLabConfig, its output is LabDocument/PracticeEmailMessage,
and its purpose is a patient's pathology result. Central reference data must not depend on the GP-sector
lab tree. Two things also make it unsafe to lean on as-is: the 5-minute task fails on every run today
(NameError: name 'PracticeMailbox' is not defined — the model is not imported in lab_documents/tasks.py,
already with OpenCode), and its message model is patient-adjacent. Reuse the pattern, in the reference-data
app — small, self-contained, and testable against a fixture.
/v1/email-ingest-style endpoint — is recorded on the CPE ConcessionsThe emails carry the same content as the published update posts, so the posts were read live to design the
parse. Verified on the day:
| Read live | What it showed |
|---|---|
Hub page cpe.org.uk/funding-and-reimbursement/reimbursement/price-concessions/ |
September 2026: 216 lines (217 rows with the header) · August 2026: 243 lines · rolled over: 9 lines |
our-news/september-2026-price-concessions-1st-update/ (16/09/2026) |
67 new lines + 9 rolled over = 76, matching that post's own sentence "To date, this takes the total … for September 2026 to 76." |
our-news/september-2026-price-concessions-6th-update/ (25/09/2026) |
36 new lines, and "… to 216." |
our-news/august-2026-price-concessions-final-update/ (04/09/2026) |
13 lines, "… to 243.", and a correction note: "the table below has been updated with an additional line in red" |
The shape of one update:
Drug · Pack size · Price concession. Values lookAcamprosate 333mg gastro-resistant tablets · 168 · £43.28.Five properties that shape the design:
The real message was read on 2026-09-26 (§16) and it changed the design in three ways. This is the corrected,
validated parse. Parse the text/plain part, not the HTML: the alert is a Mailchimp campaign whose HTML is
~200 KB of wrapper with eleven tables, and the concession table's header row is not the website's clean
three-cell Drug | Pack size | Price concession — its first cell holds the whole intro paragraph ending in the word
Drug. The text/plain alternative is the opposite: one clean, fixed-width listing of every row, present in both
real messages. So text/plain is the plan and any HTML route is a fallback.
One message carries the whole month — it is not a delta. Each alert re-states the month: the current update, then
one block per earlier update in that month, then the rolled-over block. Measured on the 6th update: 36 new +
17 + 46 + 15 + 28 + 67 earlier + 7 rolled over = 216 rows — exactly the stated total, with no overlap between
blocks. A single message therefore proves the month, and the cold-start backfill (D16) is unnecessary.
=?utf-8?Q?September=202026=20Price=20Concessions=206th=20Update?=). September 2026 → the month; 6th Update /Final Update → the ordinal, stored verbatim and as a number.the Department has today [DD/MM/YYYY] announced the following list of price concessions for <Month Year>: →The DHSC previously announced (DD/MM/YYYY) the following list … for <Month Year> → an earlier update'sThe following price concessions rolled over from <Prev Month> to <This Month> → the rolled-over rows.Drug Pack size Price concession appears once on aTravoprost 40micrograms/ml eye drops 2.5 £2.61 — so pack size is stored as text, never as an integer type; (b) an amended price —Chlordiazepoxide 5mg capsules 100 £92.86 (previously £85.45) — take £92.86, and record the previous figureTo date, this takes the total number of price concessions granted by DHSC for <Month Year> to 216. — the gate's cross-check (§7).£43.28 → 4328. A zero is never a price (the store's existing rule):2.5 ml). A numericbs4, no lxml, no HTML engine.pypdf; phase 5 needs none.veripath-ops/scripts/cpe-concession-email-parse.py — the** and dashed lines. Anchor on the sentences.cpe.org.uk), and whereThe month is the release; the emails are its updates. This is the one structural difference from phase 4
and it follows from the retention rule already in force: the purge keeps only the live and previous
release, so a release-per-email design would delete the earlier updates of the current month. One
release per month keeps the month whole, and the purge still keeps the current and previous month.
ReferenceRelease — one per dispensing month, identifier 2026-09, publication_date = the firstReferenceReleaseFile — one file per email: the retained .eml. The release's manifest names eachReferenceConcessionUpdate — one row per email: release FK · ordinal (1st/6th/final,ReferenceConcessionPrice — one row per line: update FK · drug_text · drug_key (normalised) ·pack_size (text) · price_pence (integer, never zero) · is_rolled_over ·rolled_over_from_month · row_index · drug_tariff_categories where they are ever given.ReferenceReleaseEvent trail (fetched/received, applied, held,reference_data.tasks.poll_concession_mailbox — on a 15-minute beat, plus a newReferenceSource (cpe_email, label "Email alert stream"). A manual entry pointpython manage.py reference_data_fetch --source price_concessions.INBOX) → search server-side, FROM + SUBJECT\Seen → per message, sender allowlist → parse →ReferenceFetchAttempt. Never a whole-inbox scan, and never\Seen and writes no flags (learned from the live mailbox, 2026-09-26). The mailbox isSTORE, no DELETE, no MOVE.reference_data_check gains the concession checks: the month's release carries at least one appliedThe existing health module is shaped for a publisher that issues one dated document (dm+d weekly, tariff
monthly, overdue after the 5th). Concessions are a stream inside the month, so:
next_expected_publication cannot mean the first of the next month: nothing is due at the start of aReferenceSource.uses_credential isReferenceFetchAttempt: no_message ("No email received") andignored ("Ignored — sender not recognised"), alongside the existing success/already-held/held/failed.All platform-admin (D13), none client-facing:
/infrastructure/reference-data/health/ — the price_concessions row stops reading "Not automated/infrastructure/reference-data/ — the month's release detail: the update list (ordinal, received,/infrastructure/reference-data/licences/ — the CPE row gains the consent record if and when it/medicines/ is unchanged in this phase, and stays unchanged under the present licence position — theprice_concessions source reports a real routine and cadence on the health page; "Not automated£0.00, a row with noconsent_required=True and consent not granted, the central storereference_data suite still passes.£0.00 refused; a missing pack size refused; a wrapped description; aReferenceFetchAttemptgp_booking_app (the platform application), branch feature/price-concessions-ingestion; onedefault connection (the new routine choice, the two new tables, the new attempt/app..env by Hermes with Matthew'sMatthew delegated the open calls ("apply what you think is best"), so the three design decisions are
decided and applied, recorded with the reasoning so a later session can see why. The consent item is
not mine to decide — it is a third party's to grant — and it stays open.
| # | Decision | Applied | Why |
|---|---|---|---|
| D14 | v1 scope | Ingest and store centrally, with platform-admin views only. The client-facing display stays closed until CPE's consent is recorded — the gate already exists (consent_required, may_publish), so nothing can leak early. |
The route only proves itself once messages are genuinely parsed; waiting for consent would leave the hardest part — the parse — unbuilt and unproven. |
| D15 | The mailbox | Settled in practice, 2026-09-26 (§16): the alerts already arrive in the veripath.co.uk system mailbox, so the poll targets that mailbox through the sender-and-subject-scoped, flag-free search (§3.2, §7), with its credentials in the app host's environment. A separate dedicated mailbox stays tidier but is no longer needed. |
A shared mailbox is safe because of the scoping and the read-only discipline; creating a new mailbox needs the provider's portal and now buys nothing. |
| D16 | Cold-start backfill | Superseded 2026-09-26 — no backfill is needed. The real alerts carry the whole month (measured: the 6th update's blocks sum to 216 = its own stated total), so the first message received reconciles on its own. The website read survives only as the same-day cross-check in §10. | The backfill existed to close a cold-start gap that the real message format does not have. |
Settled by decision — D10, consent — and it is a closed gate, not an open task. Matthew's decision
(2026-09-26) is that we do not send CPE a request, so the source's recorded terms (personal use only; prior
consent required before material is placed elsewhere) stand as they are and the gate stays closed: nothing
concession-derived reaches a client screen and the data is admin-side only. The full consequence — and the rule that
no later phase may quietly treat this as pending — is §15.
Two human actions remain — neither blocks the build (§3.4):
.env, and keep one real message as theBuild authorisation, as applied. With D14–D16 settled, the build is authorised exactly as specified in
this page — the parse, the upload path, the store, the gate, the poll (a clean no-op with a config alert
until the mailbox exists), the views, the health wiring and the tests — and it is briefed to OpenCode
accordingly. Not authorised: any client-facing display (including any change to /medicines/); matching
concession rows to dm+d packs; the reimbursement guardrail; anything that writes to a tenant database; and any
acquisition route other than the mailbox in §3.
Updated 2026-09-27. Two decisions, made a day apart, and the second changes the target.
1. No request is sent to CPE (Matthew, 2026-09-26). So the source's recorded terms stand as they are (copying is
authorised for personal use only; material may not be placed elsewhere without prior consent) and the code gate
therefore refuses to publish this source to clients — consent_required=True, may_publish false, no live
release. That is the gate doing its job, not a placeholder for a pending action.
2. The data is to be made available to clients (Matthew, 2026-09-27), and he owns the necessary licensing tasks.
The direction is therefore settled: concession data will reach client screens, and the licensing basis is
Matthew's to record rather than a phase deliverable. Three consequences, in order:
consent_granted (with the note, the date and what it rests on) or, if he judges the requirement not to apply,consent_required=False with the reason written down. Until that record exists the display stays closed byThe mailbox was found, its messages were read, and the parse was validated against them. Everything below was
observed, not assumed.
The mailbox already receives the stream — no new mailbox was needed. The veripath.co.uk system mailbox that the
host's alerting already uses holds CPE's alerts: the 5th and 6th September 2026 price-concession updates
(24 and 25 September) along with CPE's newsletters. (The folder created in webmail was empty — no server-side filter
exists to fill it — and it has been deleted; the poll needs no folder.) The mailbox is shared with other mail
(76 messages, 34 unseen when checked), which is why the scoped, flag-free search in §3.2/§7 is a safety requirement
and not a nicety.
Egress and credentials proven from the application host, not from a diagram. A one-shot connection from the app
host returned IMAP connect + login OK, listed the folder, and ran the server-side scoped search — so the app
host reaches the mailbox and the credentials work. The configuration group is in place in the application's
environment file (REFERENCE_DATA_CONCESSIONS_IMAP_*, eight keys; the previous file kept as a dated backup). The
values were transferred over stdin and have never been printed, logged or written to a wiki page.
The parse was validated against both real messages — and it corrected three assumptions:
| The design assumed | What the real message does |
|---|---|
Anchor on the HTML table headed Drug / Pack size / Price concession |
The HTML is a Mailchimp wrapper with 11 tables, and the concession table's first header cell contains the whole intro paragraph. Parse text/plain instead — one clean, fixed-width listing. |
The subject reads September 2026 Price Concessions 6th Update |
True in substance, but the subject arrives Q-encoded and must be MIME-decoded first. |
| Each message is a delta, so cold start needs a backfill | It is not a delta. Each message re-states the month — the Department has today [DD/MM/YYYY] …, then The DHSC previously announced (DD/MM/YYYY) … once per earlier update, then the rolled-over block. D16 is superseded. |
The arithmetic gate validated exactly, on real data:
£92.86 (previously £85.45)) + 17 + 46 + 15 + 28 + 67 + 7 rolledTwo row shapes only the real message revealed: a fractional pack size
(Travoprost 40micrograms/ml eye drops 2.5 £2.61) and an amended price (£92.86 (previously £85.45)).
A hazard found and closed. The September updates had already been read in webmail, so an UNSEEN-only poll
would have found nothing and missed the month silently. The poll therefore ignores \Seen, re-reads its scoped
search every run, de-duplicates on Message-ID and body hash, and never writes a flag.
Artifacts kept: both messages as .eml in the platform host's drop directory (read-only from the mailbox, hashes
taken), and a working reference parser at veripath-ops/scripts/cpe-concession-email-parse.py — the prototype
that produced every figure above.
One unrelated note from the same mailbox: CPE's "Main Newsletter: Please Confirm Subscription" (23 September) is a
pending double opt-in — and the concession alerts demonstrably do not depend on it, since the 5th and 6th updates
arrived regardless. Worth confirming only if the general newsletter is wanted.
Built by OpenCode (8abc4bd), deployed to the app host and verified live the same night. The bundle
/tmp/concessions-8abc4bd.bundle moved the app checkout 5bf0857 → 8abc4bd on branch
feature/price-concessions-ingestion; migration reference_data/0008 was already applied by the entrypoint on
container start; no image build was needed (stdlib only) — the containers were recreated only to pick up
configuration.
One finding on the way in, and it mattered. The application reads config/secrets/env.production (env_file in
the compose file), not .env — .env only feeds compose's ${…} interpolation. The concession group had first
been written to .env, where it was invisible to the container (docker compose exec app env showed nothing); it now
lives in env.production (dated backup kept, and the file tightened from 644 to 600), the duplicate block was
removed from .env, and all eight keys are confirmed visible inside the running container.
python manage.py reference_data_fetch --source price_concessions:
Price Concessions: New release imported — 2026-09 5th: applied 182 rows (stated 182).
| 2026-09 6th: applied 216 rows (stated 216).
release pk=4 id=2026-09
Both messages were ingested, each reconciling to its own stated total, and applied in order (5th then 6th, by
confirmed date — not by arrival). The poll used the scoped, flag-free search; no message was marked seen and nothing
else in the mailbox was touched.
A re-runnable live verifier (kept: veripath-ops/scripts/verify-concessions-live.py, run inside the app container):
cpe_email ("Email alert stream"), cadence monthly, consent_required=True, may_publish2026-09 with two retained .eml files, each with its sha256, and the attribution in its licencerow_count and stated_total 182 / 216,3170926, the Drug Tariff still live at 2026-09 with all four(Two failures in the first run of the verifier were my harness's bugs, not the build's — a rendered display value
compared against an integer, and a dict collapsing two rows that share a drug name. Both corrected, and the record kept
here because the lesson is worth more than a tidy log: parse both sides to the same unit before comparing.)
manage.py test reference_data under dsp_clinic.test_settings inside the app container — 136 tests, OK
(1 skipped), and its own reference_data_check output ends "attribution: present / All checks passed."
migrate reports that the infrastructure and integrations apps have model changes not reflected in migrations.
That predates this build (its diff touches neither app) and is recorded here rather than fixed: it is someone's
outstanding work, not a phase 5 defect. The earlier lab_documents IMAP NameError and the reference_data_check
closure cost also remain open in from-hermes, both outside this build.
Direction set by Matthew. Ingesting the month is done; the following is what stands between the store and a contractor
seeing a concession price, in the order the dependencies actually run.
The licensing basis recorded on the register — consent_granted with its note and date, or
consent_required=False with the reason. Matthew's task, and the only thing that opens the gate; until it is
recorded the code refuses to publish the source and the client screens stay unchanged (§15).
Matching concession lines to dm+d packs — the real work. Every row is a free-text description and a bare pack
size (Aciclovir 400mg tablets, 56); dm+d names its packs its own way. So matching is a normalisation problem
with three outcomes: matched with confidence, ambiguous, and unmatched — and the tolerance has to be decided by
someone who knows the packs, not by a similarity score alone. Until it is solved the display can only show the
concession list; with it, the display can show the contractor's own pack at this month's price.
Correction, 2026-09-27 — measured, not re-argued. The paragraph above calls this "the real work". Measured
against 68 months and 8,848 lines (every notice the page publishes), it is a normalisation rule: with the
pack size split into number and unit, 98.8% of all history and every line of the last 16 months (2,626
lines) match, with 0 ambiguous, 0 without a pack, and no fuzzy matching anywhere. The three outcomes
stand; what the tolerance turns out to be is a deterministic rule and an empty queue, not a judgement call. The
real remaining work is the surfaces and re-validating links as dm+d moves weekly — spec:
Data Feeds — the Pack Link.
The client-facing surface itself — the natural home is the pharmacy view beside the pack, since that is where a
contractor decides whether to buy: the pack, this month's concession price, and the fact that the line is
discount-not-deducted for that month (which is the part that changes the economics, not just the price).
Needs: a small spec, the attribution statement on the surface, and a live check on a client tenant host
(the phase-4/5 pattern), not just on the apex.
The guardrail coupling — concession lines are zero-discount for the month, so any reimbursement comparison
built later must read the concession price and the discount position together. Recorded here so the two are not
built apart.
Retention and rollover in the display — a newly published month must supersede cleanly on the screen, and a
rolled-over line must show as this month's price, not last month's artefact.
Recommendation: take items 1–3 as the next phase and spec them together — the display is a small piece, the
matching is the substance, and they are useless apart. Item 4 stays with the guardrail's own spec; item 5 is an
acceptance criterion of the next phase rather than separate work.
The problem this answers. §15 records CPE's terms as requiring CPE consent before publication to client
pharmacies, and no consent has been sought. Matthew, 2026-09-27: "if that is what you are saying about the CPE in terms
of licensing then this approach will not achieve the purpose" — the purpose being that a contractor compares their own
supplier purchase price with the NHS position. So the question put to the sources was: do the same monthly values
arrive from a source we may already publish?
They do — from NHSBSA, on the Drug Tariff pages, under the licence we already hold. The
hub page's updates tab
carries, month by month, a series of dated "Drug Tariff News — Part VIIIA reimbursement prices (issued
DD Month)" notices, each opening "The Department of Health and Social Care has agreed the September Drug Tariff Part
VIIIA reimbursement prices for the following…" and then listing the drug, the pack size and the price — the same
concession prices, from the body that agrees them, published by the Drug Tariff's own publisher.
September 2026, read live on 2026-09-27: seven notices — issued 04, 16, 17, 18, 22, 24 and 25 September —
carrying 216 rows over 216 distinct (drug, pack) lines. Our own store's effective set for the same month is 216
lines. Compared line by line:
| Count | |
|---|---|
| on the NHSBSA page, not in our store | 0 |
| in our store, not on the NHSBSA page | 0 |
| prices differing | 0 |
The 04 September notice is the rolled-over block (the prices granted in August rolled into September), which is
exactly the rolled-over rows our stream holds. Two independent routes agree completely — and the 25 September notice
carries Chlordiazepoxide 5mg capsules 100 at £92.86, the amended figure the live verifier spot-checks.
Why this settles the licensing question. Our register records the NHSBSA Drug Tariff source as "NHSBSA copyright,
reusable under the Open Government Licence v3" with consent_required = false — and the tariff ingestion
already fetches the same publisher's pages monthly, unattended, with no account and no key (§4 of the tariff spec).
So the values a contractor needs can reach a client screen on a licence we already hold and already exercise.
The recommended shape — two sources, one of them the one that publishes.
| Source | Role | Gate | |
|---|---|---|---|
| CPE email alert stream (this phase) | CPE | Alert and cross-check: it arrives unattended, carries the whole month, and reconciles to its own stated total — a change detector and an independent verifier | stays closed; nothing concession-derived reaches a client from CPE, as its terms require |
| NHSBSA Drug Tariff News notices (new) | NHSBSA / DHSC | The record the client sees, each figure citing the notice and its issue date | open — OGL v3, no consent |
| the two agree line for line, which is itself the acceptance test |
Not a workaround, and not a fiction. The concession price is DHSC's decision; CPE publishes it because CPE agrees it
with DHSC, and NHSBSA publishes it because the Drug Tariff must state what will be paid. Taking it from the publisher
whose terms permit republication is the ordinary way round — and it strengthens provenance, because each figure then
cites a dated DHSC/NHSBSA notice rather than a mailing.
Two consequences worth stating plainly.
name pack £price), notAsked by Matthew, 2026-09-27: "the CPE Concession data is published throughout each month in several bulletins — is
each email going to prompt the app to get more data from the NHSBSA?" Answer: no trigger is needed, because NHSBSA
publishes on the same intra-month rhythm. Counted from the page itself, notice by notice:
| Month | Notices | Issue dates | Rows |
|---|---|---|---|
| September 2026 | 7 | 04, 16, 17, 18, 22, 24, 25 September | 216 |
| August 2026 | 9 | 04, 14, 18, 19, 25, 27, 28 August · 02, 04 September | 243 |
| July 2026 | 8 | 03, 15, 17, 21, 22, 29, 31 July · 04 August | 226 |
| June 2026 | 7 | 03, 19, 24, 26, 30 June · 02, 03 July | 257 |
| May 2026 | 7 | 05, 15, 20, 22, 28 May · 02, 05 June | 232 |
| April 2026 | 7 | 02, 17, 21, 24, 28, 30 April · 05 May | 216 |
So the bulletins are the same bulletins: seven to nine dated notices a month, matching the CPE stream's rhythm (its
5th and 6th September updates were confirmed on 24 and 25 September; the page carries notices issued on both those days).
An amendment is visible the same way, which is what proves the two are the same series and not a monthly snapshot:
| Notice | Line |
|---|---|
| 02 September | Chlordiazepoxide 5mg capsules 100 — £85.45 |
| 25 September | Chlordiazepoxide 5mg capsules 100 — £92.86 (the amended figure our verifier spot-checks) |
How it therefore works, and it needs no CPE involvement:
Two things worth noting: the notices are dated, so every figure can cite the notice that granted it (§19's
provenance point); and the 02–05 of the following month's notices sit under the previous month's heading (the
close-out of that month), so the month a notice belongs to is read from its heading, never inferred from the issue
date.
Matthew's steer: "I think it could be used to ensure the fresh CPE Concessions data has been fetched — nothing else
— that way we are not using the content of the email to publish to clients and instead using the data sourced from
NHSBSA." This settles the design: the mailbox is retained for exactly one purpose — a change signal.
| The mailbox (CPE) | The page (NHSBSA) | |
|---|---|---|
| Role | signal: "fresh concession data exists — go and fetch" | content and source of record |
| What is read | the envelope — sender/domain and subject, with the Message-ID as the "seen this one" ledger | the notices: drug, pack size, price, issue date |
| What is stored | the Message-ID and the received time — not the body, not its prices | the month's release, its notices and updates |
| What it does | triggers the NHSBSA fetch immediately, instead of waiting for the next poll | appends the new notice as an update, latest notice wins |
| If it fails | nothing breaks — the daily NHSBSA poll is the safety net | unchanged |
| What a client sees | nothing, ever — the gate stays shut | the figures, with the notice and its date |
The rules that keep it a signal:
What this changes in the phase-5 build (for the next handover, not yet built): the mailbox routine stops parsing a
body and stops retaining .eml files; it reads the envelope, records the Message-ID, and triggers the fetch. That
removes the arithmetic gate, the .eml retention and the price extraction from the client-facing path entirely — the
simplification Matthew's steer buys. Nothing already built is discarded: the two retained .eml files and the 398
imported rows stay exactly as they are, the historical record of how the month was first acquired, with the gate shut
and nothing from them displayed to a client. The one-off equality proof in §19 (216 = 216, 0/0/0) stands as the
evidence that the source change loses no data.
Matthew's point: "the email alerts also allow the app to be more resourceful because probing every 15 minutes must be
using a fair amount of resources?" The principle is right; the number is small, and the design already removes the
part that was heavy. Measured, not estimated:
| Thing | What it actually is | Cost |
|---|---|---|
| The mailbox check (15 min) | one TLS session + LOGIN + a server-side scoped SEARCH + LOGOUT. It is mostly waiting on the network, not CPU — a sleeping worker waking 96×/day | Small. It was never the heavy item |
| The body parse it used to do | per bulletin: ~200 KB of HTML, 11 tables, ~200 KB of .eml retained forever |
This was the heavy part — and the signal design (§19.2) deletes it entirely |
| The NHSBSA page | 583,901 bytes (~570 KB) — one HTML document, the whole multi-month changelog | A daily poll is ~17 MB/month; trivial bandwidth, and the parse is bounded (below) |
Probed and rejected today, so it is not tried again. The page cannot be checked or fetched cheaply:
| Attempted | Result |
|---|---|
If-Modified-Since (conditional GET) |
200 with the full 570 KB — the header is ignored, so no 304 |
Range: bytes=0-131071 (partial fetch of the current month, which sits at the top) |
200 with the full 570 KB — ranges are ignored |
HEAD + Last-Modified as a change signal |
Last-Modified reads the current day (today 06:03), i.e. the page is regenerated on a schedule, so it moves whether or not the content did. Not usable as a change signal |
What that leaves, and the three consequences for W6:
ReferenceFetchAttempt), so the check