# 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:

- it does not store a copy of ICD-11. WHO's own container is the runtime
  vocabulary, and nothing is copied (which also retires most of the CC BY-ND
  question — no stored copy means no derivative work and no redistribution)
- it does not mint canonical URIs. termbank.pdhc owns those (Rule 27); this
  service reads them
- it does not hold a user table. Authentication and admin come from sso.pdhc

## 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:

- **the assertion log is append-only in three places** — no UPDATE path in the
  code, a database trigger, and a test. A correction is `entered-in-error` plus
  a new assertion, never an edit.
- **`encounter_diagnoses` carries a PARTIAL unique index** on `(encounter_ref)`
  where `use_code='CC' and rank=1`, so a second primary diagnosis is refused by
  the database. SQLite ignores partial indexes, which is why the suite must also
  run against Postgres before a deploy.

## 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

```json
{ "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:

- **two of the plan's original seven rules were wrong**, and were corrected by
  measuring WHO rather than by reasoning. "At most one extension per axis" is
  false — seven axes accept several values. And a rule was *missing*: prefer the
  pre-coordinated code, which WHO's own tooling does silently. Without it two
  identical statements get different `cluster_hash`, breaking idempotency and
  double-counting in the prevalence table.
- **`R4-same-block` cannot fire on the real path** (#861). WHO's `describe`
  prunes a same-block pair to one value before `check_multiplicity` sees the
  cluster, so the axis is already single-valued. `R5-silent-drop` is what
  actually refuses it. Safe, but R5 is then the only guard there.

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.
