Technical manual
Current as at 2026-10-22, after steps 1–7. Rule 25.
This file replaces a stub that still said "schema: none yet" and was written
at step 1.a. It had been wrong for every step since, which is its own lesson:
a document that describes an earlier version of the service is not a partial
document, it is a misleading one.
The authoritative design remains ../documents/implementation_plan.md. This
file describes what the code does.
It records a diagnosis as a temporal episode with an append-only assertion
log, expressed in ICD-11 (MMS) or in ICD-10-SE during the transition, and
projects it into cdr.pdhc as a FHIR Condition.
What it does not do, deliberately:
| ports | app 9150, db 9151, WHO icd-api 9152 (block 9150–9159, Rule 16) |
| containers | icd11_pdhc_app, icd11_pdhc_db, icd11_pdhc_icdapi |
| compose project | icd11_pdhc — pinned, or a release swap forks a second project |
| database | own Postgres 16, volume icd11_pdhc_pgdata (Rule 5) |
| entry point | ./start.sh (Rule 16) |
| public name | icd11.pdhc.se (operator decision 2026-10-22, reversing an earlier diagnos.pdhc.se). Repo and containers are icd11* either way |
The WHO container is digest-pinned (whoicd/icd-api@sha256:1b77eb6d…), runs
offline after first start, needs no credentials to query, and carries the
classification in its image — there is no data volume. The cloud API is used
only to ask whether a newer release exists, never on the path of recording a
diagnosis. That is what keeps plan §8 degraded mode achievable.
First deploy: deploy/SERVER_DEPLOY.md.
11 tables.
| table | holds |
|---|---|
diagnosis_codes |
a validated cluster: canonical expression, cluster_hash, release, linearization |
diagnosis_code_parts |
WHO's decomposition of it — stem and extensions, with axis and role |
diagnosis_episodes |
the episode: patient, patient_org_guid, onset, status |
diagnosis_assertions |
the append-only log. No UPDATE path exists |
encounter_diagnoses |
primary/secondary per encounter, with a partial unique index |
used_concepts |
every concept actually asserted, with its display as at assertion |
mapping_decisions |
ICD-10-SE → ICD-11 bindings, as facts at a time |
code_system_policy |
per (system, release): may a NEW statement still use it |
cdr_deliveries |
the projection queue |
read_audit |
Rule 24 reads |
alembic_version |
migrations |
Two schema facts the design leans on:
entered-in-error plusencounter_diagnoses carries a PARTIAL unique index on (encounter_ref)use_code='CC' and rank=1, so a second primary diagnosis is refused byAll /api/v1/* routes are @require_auth.
Entry
POST /api/v1/episodes |
open an episode + assertion #1, manual path |
POST /api/v1/episodes/<guid>/assertions |
append an assertion |
POST /api/v1/fhir/Condition |
FHIR ingest; accepts ICD-11 or ICD-10-SE |
GET /api/v1/validate |
validate a cluster without storing it |
GET /api/v1/autocode |
WHO search, for the entry form |
GET /api/v1/axes?code= |
the postcoordination axes of a code's stem, with every value each one accepts |
GET /api/v1/axis-search?q=&chapters= |
codes matching text, restricted to an axis's chapters |
Read
GET /api/v1/patients/<guid>/diagnosis-summary |
the problem list; ?as_of= replays it from the log |
GET /api/v1/episodes/<guid>/history |
every assertion for one episode |
GET /api/v1/encounters/<ref>/diagnoses |
primary/secondary for an encounter |
GET /api/v1/mapping-decisions |
the ICD-10-SE queue a coder works |
Reports
GET /api/v1/reports/release-drift |
stored codes that are behind, gone or unbound |
GET /api/v1/reports/mapping-rebind |
no_map decisions the CURRENT map would now answer |
GET /api/v1/used-concepts |
the export termbank's importer reads (step 2.f) |
Operational
GET /healthz, GET /api/v1/health |
the same endpoint |
GET /api/v1/release-currency |
is a newer WHO release available |
GET /, /ct, /patient |
the clinician UI |
{ "status": "ok" | "degraded",
"service": "icd11.pdhc",
"database": "connected" | "unavailable",
"version": "<image build stamp>",
"icd11_release": "2026-01" | null,
"icd11_source": { "configured": true, "reachable": true } }
200 for ok, 503 for degraded. The database is really probed with
SELECT 1, and WHO is really probed — an unreachable WHO is degraded, because
validation is delegated and without it a diagnosis cannot be recorded at all.
Reporting ok would be the false green CLAUDE.md §10 exists to prevent.
icd11_release is null when unset: the check did not run, which is not the
same as false. configured and reachable are separate fields for the
same reason (#833): "nobody wired this up" is terminal, "it blipped" is
retryable, and reporting the first as the second tells a caller to retry
forever.
Access-Control-Allow-Origin: https://www.pdhc.se is set so services.html can
read the body and drive a real dot.
Release currency is a separate endpoint on purpose. Being behind a release
is an operational decision, not a health problem, and /healthz must answer
without leaving the machine.
Seven rules in app/services/cluster.py, each with a test that fails without
it. Two things are worth knowing before changing any of them:
cluster_hash, breaking idempotency andR4-same-block cannot fire on the real path (#861). WHO's describecheck_multiplicity sees theR5-silent-drop is whatEvery rule fails closed. An unresolvable block, an unknown axis, an
unreachable WHO — each refuses rather than passing by omission.
ICD-11 has two separators and WHO refuses the wrong one. Measured against
the container, release 2026-01:
& attaches an EXTENSION code (chapter X) to the code immediately before it
/ joins STEM codes
5A11&5A20 -> "Invalid code after &" (5A20 IS a valid value here)
5A11/5A20 -> accepted
NA00&XJ1C6/8A84.1 -> accepted
NA00/8A84.1&XJ1C6 -> refused: XJ1C6 reads as qualifying 8A84.1, not NA00
So & binds to the nearest preceding stem code and position is semantic.
An axis is not homogeneous either: NA00 takes XJ1C6 on typeOfInjury and
8A84.1 on hasManifestation. The separator therefore belongs to the VALUE,
and axis_picker.separator_for derives it from the code's own chapter-X
membership — the same rule who_client._is_extension_chapter uses.
cluster.canonical_code emits the only ordering WHO accepts: the stem, then its
extension codes sorted by (axis, code), then each stem-code value as its own /
group. Extensions must precede the first /. The canonical string is what is
stored and hashed, so an invalid ordering here is a corrupt record, not a
cosmetic flaw.
A value may itself be a cluster. describe on NA00/8A84.1&XS5B reports
one hasManifestation value whose code is the composite 8A84.1&XS5B — the
nesting is carried inside the value and nothing is lost. cluster.lost_parts
therefore compares ATOMS on both sides; a set of whole part codes makes every
atom inside a composite look dropped.
axis_picker — what a valueset actually containsaxes() returns scaleEntity URIs, which are not a value list:
| a category | a value |
| a block | expanded into its children, which may be blocks again (deepest measured: 5) |
a residual URI (/other, /unspecified) |
fetched like any other and resolves to a real codable category — .../168436253/other -> 8C12.Y — and is flagged, not dropped |
| a chapter | a SCOPE, never a value. CB41's required hasCausingCondition is 14 whole chapters, and offering one as a value would store CB41/01 — a code WHO accepts and that says nothing |
An axis is therefore enumerated, search or mixed, and kind is derived
from the two lists so it cannot disagree with them. Anything unresolvable —
a 404 on one value, depth exhaustion (MAX_DEPTH=10), the MAX_VALUES=5000
cap — is reported in unresolved; a WhoUnavailable propagates, because an
axis emptied by an outage is indistinguishable from an axis with no values.
41 axes, and the set is closed. Release 2026-01 walked in full: 37 212
entities, 41 distinct axis names, census in
app/tests/fixtures/axis_census_2026-01.json. axis_labels.AXIS_LABELS_SV is
therefore complete rather than partial, and a test asserts it covers exactly the
census so a new release fails loudly instead of showing one English row. Six
axes are ever required, and hasCausingCondition is required on 880 of its 921
declarations.
Three calls and nothing more: codeinfo, the postcoordination scales, and
search. Plus describe, which replaces a local parser — the parser being
exactly where the two wrong rules lived.
Entity documents are cached by (base_url, release, language, uri), which
is safe because a release is immutable and the container is digest-pinned. Only
entity fetches by URI use it (axes, entity_facts, block_of); codeinfo,
describe, autocode and check stay live. check matters most: a cached
health probe would report WHO reachable from memory while the container was
down. Excluded by construction, not by a flag.
Measured effect: 1A00&XN8P1 went from 11 WHO calls to 4 cold / 3 warm,
because block_of walks up to 8 hops and each hop is its own request.
WhoNotConfigured / WhoUnavailable / WhoRejected / WhoReleaseMismatch are
distinct by design. There is deliberately no default base URL.
| peer | relationship |
|---|---|
| WHO icd-api | the runtime vocabulary. Ours, in our own compose, offline |
| termbank.pdhc | owns canonical URIs (Rule 27). This service registers the concepts it has used and reads URIs back; it never mints one |
| plan.pdhc | $validate-code decides whether a code system is adopted. icd11-mms is a whole_system_answer_space library, so plan assents to the SYSTEM — not to membership (#857) |
| cdr.pdhc | the clinical repository of record. This service projects Condition with code_canonical = https://termbank.pdhc.se/CodeSystem/icd11-mms/<code> |
| ips.pdhc | patient identity and the patient's organisation. Client built; IPS_RESOLVE_PATIENT_ORG is off until verified against a real ips (#858) |
| sso.pdhc | authentication and is_su_admin. The _ICD11 pair has never been exercised (#859) |
Scoping is on the episode's patient_org_guid — the patient's organisation
— never the recorder's. They are different identifier spaces, and conflating
them is a real defect elsewhere on the platform: request.pdhc compared sso
organisation guids to ips Clinic.guid and 0 of 9 live clinics matched, so the
gate denied every non-SU caller for months (#779).
An empty scope returns nothing, not everything. An admin is scoped like
anyone else unless X-Admin-Read-Reason is given (break-glass, audited).
Today patient_guid and patient_org_guid come from the entry form, so
Rule 24 scopes on typed values until ips is wired. Acceptable only because this
phase is synthetic-data-only.
The factory raises rather than starting degraded:
DATABASE_URL — there is deliberately no fallback DSNSECRET_KEYAUTH_MODE != sso under FLASK_ENV=production (Rule 24)All three are skipped under TESTING. Compose adds a fourth:
${DB_PASSWORD:?} refuses to start without the key, because a ${KEY:-value}
default is a working credential whenever the key is missing (#849).
flask create-suRule 23 wants an SU bootstrap possible on first deploy, and here it already is.
This service has no user table, and app/auth.py derives admin from the SSO
blob (is_su_admin). An SSO SU admin has admin here as soon as the _ICD11
pair is registered. An empty create-su would imply a local identity store this
service must never grow.
app/.venv/bin/python -m pytest -q # SQLite
TEST_DATABASE_URL=... app/.venv/bin/python -m pytest -q # Postgres — before any deploy
app/.venv/bin/python -m pytest -q -m integration # live, against the WHO container
324 passed / 1 skipped on Postgres as at 2026-10-22. The Postgres run is not
belt-and-braces: SQLite silently ignores the partial unique index step 4 relies
on, so a constraint ignored in the suite would fail on the server instead.
Many tests are deliberately controls — the old logic restored inside the new
structure, to prove the test fails without the fix. A control that dies on an
import error never ran the assertion. Two controls in this module have passed,
each time revealing a test that was asleep.