All documentation Download (Markdown) Technical

ICD11 · icd11.pdhc.se

Technical manual


icd11.pdhc — technical description

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.


1. What this service is

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:

2. Deployment shape

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.

3. Schema

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:

4. HTTP surface

All /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

Health contract

{ "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.

5. Validation — WHO decides, this service normalises

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:

Every rule fails closed. An unresolvable block, an unknown axis, an
unreachable WHO — each refuses rather than passing by omission.

5b. Postcoordination — the two separators, and who holds which rule

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 contains

axes() 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.

6. The WHO client

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.

7. Where this service sits

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)

8. Rule 24

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.

9. Startup refusals

The factory raises rather than starting degraded:

  1. no DATABASE_URL — there is deliberately no fallback DSN
  2. the placeholder SECRET_KEY
  3. AUTH_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).

10. There is no flask create-su

Rule 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.

11. Tests

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.