# icd11.pdhc — användarhandbok / user manual

Current as at **2026-10-22**, after steps 1–7. Rule 25.

This replaces a stub which said *"There is no user-facing surface yet"* and
*"No diagnosis can be entered, read, or reported."* Both were true at step 1.a
and false from step 5 onward.

Three readers: the **clinician** who records a diagnosis, the **coder** who
resolves an ICD-10-SE mapping, and the **operator** who runs the service.

> **Synthetic data only in this phase.** No real patient data, not even
> de-identified. The interaction is what is being measured, not the patient.

---

## 1. For the clinician

The service answers one question the current record cannot: **what did we think
was wrong with this patient, and when did we think it.**

### Record a diagnosis — `/` ("Registrera diagnos")

Four steps on one page.

**1 · Hitta koden.** Type the clinical text you would write anyway, in Swedish.
The service asks WHO, which answers with a **stem code** — and often with an
unspecified one. The match text and score are shown for that reason: a residual
answer is correct behaviour, and you have to see that it happened.

**2 · Kontrollera koden.** Paste or type a code instead if you prefer. Nothing
is stored on an unvalidated code.

**3 · Precisera (postkoordinering).** This is where ICD-11 earns its keep. A
stem code says *what*; the axes say *how* — severity, side, cause,
manifestation. Only the axes WHO permits for your code appear, and only the
values each one accepts:

| you see | it means |
|---|---|
| a dropdown, grouped | the axis has a listable set of values — 1 to 571 of them |
| a search box over named chapters | the axis accepts anything in those chapters, so there is nothing to list. `Orsakande tillstånd` is 14 whole chapters |
| **obligatorisk** | ICD-11 marks this axis mandatory for this code ("code also"). Leave it empty and step 2 refuses |
| *ett värde* / *flera värden* | whether a second pick is added or **replaces** the first |
| *restkategori* on a value | "annan specificerad" / "ospecificerad" — allowed, and counted in the coding-depth follow-up |

Every pick rewrites the code in step 2 and re-checks it against WHO. Remove one
with the **×** on its chip.

**Why there is a picker here at all.** An earlier version of this manual sent
you to WHO's own Coding Tool for anything beyond a stem code, on the argument
that building a second one would mean reimplementing rules this project had
already got wrong twice. The picker avoids that by **not holding any of those
rules**: which axes exist, which are mandatory, how many values each takes and
whether a value belongs are all read from WHO's own `postcoordinationScale` and
judged by WHO's own validation. What the page contributes is the two things WHO
does not do — it refuses a value WHO would silently discard, and it assembles
the parts in the one order WHO accepts. `/ct` is still there if you would rather
work in WHO's tool and paste the result.

**4 · Registrera.** Then:

| field | note |
|---|---|
| **Patient-GUID** | typed today. ips is not yet wired (#858) |
| **Patientens organisation (ips)** | the *patient's* organisation, not yours. Every access filter reads this, and it **cannot be corrected later** — ips keeps no assignment history |
| **Debut** | mandatory. A diagnosis without an onset cannot answer a question about time |
| **Klinisk status / Verifiering** | `active` / `confirmed` by default |
| **Vårdkontakt** + **Roll** + **Rank** | optional. Rank 1 with role `CC` is the primary diagnosis, and the database refuses a second one for the same encounter |

**Nothing is stored unless WHO validates the code.** If WHO cannot be reached
you get a 503 and *nothing is written* — "could not validate" is never recorded
as "valid".

### What happens to a mistake

**Nothing is edited and nothing is deleted.** A wrong diagnosis is corrected by
recording a new assertion — typically `entered-in-error` — and the original
stays visible. This is the point of the service rather than a limitation: the
record has to keep showing what was believed at the time, or it cannot answer a
question about the past.

### Read a patient — `/patient?patient=<guid>`

The problem list, plus the control that carries the whole argument: **as-of**.
Set a date and you see the list *as it stood then*, rebuilt from the assertion
log — not the current list with old rows hidden.

That is what makes "the same question gives the same answer" something you
click rather than something you are told.

### ICD-10-SE during the transition

ICD-10-SE codes are accepted and **recorded as ICD-10-SE**, with the ICD-11
binding declared separately. A code is never silently converted:

| state | meaning |
|---|---|
| `auto` | the map gave exactly one ICD-11 answer |
| `pending` | several answers — **a coder chooses** |
| `resolved` | a coder chose |
| `unmappable` | the map answered and ICD-11 has no equivalent |
| `no_map` | **we could not ask.** A statement about us, not about the code |

The distinction between `unmappable` and `no_map` is deliberate: one is a fact
about ICD-11, the other is a missing data source, and they are fixed by
different people.

## 2. For the coder

### The queue — `GET /api/v1/mapping-decisions`

Every ICD-10-SE code waiting on a decision.

### Two reports worth reading before you act

**`GET /api/v1/reports/release-drift`** — which stored codes are `behind` a
release, `gone` from the current one, or `unbound` (no ICD-11 expression at
all). Re-expressing one is a **new assertion**, never an edit.

**`GET /api/v1/reports/mapping-rebind`** — which `no_map` decisions the current
map would now answer. It changes nothing; it tells you where a new assertion
would now succeed.

### Read `auto_safe`, not `actionable`

This is the one thing in the manual that could cost you something.

A *unique* map answer is not a *faithful* one. Measured on the pilot data:

```
E11.2  Diabetes mellitus typ 2 - MED njurkomplikationer   ->  5A11
E11.9  Diabetes mellitus typ 2 - UTAN komplikationer      ->  5A11
```

Both resolve uniquely to the same ICD-11 code. Apply both and two clinically
different statements merge, and the kidney complication is gone. WHO marks both
as `modifiedcategory`: in ICD-11 that modifier lives outside the stem and needs
postcoordination, so a one-to-one binding cannot carry it.

So the report gives you two numbers. **`actionable`** is how many a new
assertion *could* bind. **`auto_safe`** is how many no other source code also
claims. Rows that collide name each other in `collides_with`. Those are yours
to decide, not the map's. See #860.

## 3. For the operator

### Start it

```
cd ~/T7_sidewinder/icd11.pdhc
cp .env.example .env        # then fill it in
chmod 600 .env              # miserver has three local accounts
./start.sh
```

`.env` must be filled first. `DB_PASSWORD` has **no default**: compose is
written `${DB_PASSWORD:?DB_PASSWORD must be set}` and refuses to start without
it. Deliberate — a `${KEY:-value}` default is a working credential whenever the
key is missing, and termbank ran on the literal `changeme` for seven weeks that
way.

`AUTH_MODE=off` is **local dev only**. The factory refuses to start with
anything else under `FLASK_ENV=production`.

**There is no `flask create-su`** and none is needed: this service has no user
table, and admin comes from the SSO blob's `is_su_admin`. An SSO SU admin has
admin here as soon as the `_ICD11` pair is registered in sso.pdhc.

### Check it

```
curl -s http://127.0.0.1:9150/healthz
```

`"status":"ok"`, `"database":"connected"`, HTTP 200. Two degraded states, and
they mean different things:

| | |
|---|---|
| `"database":"unavailable"` | the app is up, the database is not |
| `"icd11_source": {"configured": true, "reachable": false}` | WHO is down. **No diagnosis can be recorded**, because validation is delegated |
| `"icd11_source": {"configured": false}` | WHO was never wired up. Terminal — retrying will not help |

The last two are separate fields on purpose: "nobody configured this" and "it
blipped" need different responses, and reporting the first as the second tells a
caller to retry forever.

### Routine jobs

```
flask projection-status --target <cdr>     # what is queued for cdr
flask project-to-cdr --target <cdr>        # drain it
flask register-used-concepts               # step 2.f, before a termbank import
```

The projection queue is drained on demand. **Recording never waits for cdr** —
a diagnosis is stored whether or not the projection succeeds, and the queue
retries.

### Is a newer WHO release out?

```
curl -s http://127.0.0.1:9150/api/v1/release-currency
```

Separate from `/healthz` on purpose: being behind a release is an operational
decision, not a health problem, and `/healthz` must answer without leaving the
machine. `"checked": false` means the cloud credentials are not set — the
service runs perfectly without them.

### Upgrading the WHO container

Don't, casually. The image is **digest-pinned** and every response is checked
against `ICD11_RELEASE`; a mismatch is a loud error, not a silent upgrade. A
release must never move under stored diagnoses (Rule 28). Changing it is a
deliberate operation with a drift report afterwards.

### Stop it

```
docker-compose stop
```

Never `colima stop`, `colima delete` or `colima restart` — every other PDHC
service's containers live in the same VM.

## 4. Known limits, stated plainly

- **ips is not wired.** `patient_guid` and the patient's organisation are typed
  into the form, so access scoping rests on typed values (#858, #859).
- **A nested cluster can only be entered by hand.** The picker offers the axes
  of the code's own stem. ICD-11 also allows a value to carry its own
  qualifier — `NA00/8A84.1&XS5B`, severity on the *second* stem — and that
  validates and stores correctly if you type it, but no panel builds it.
- **The SSO path has never been exercised** against a real SSO. It is the one
  genuinely unverified thing in the service (#859).
- **Only WHO's pilot mapping subset is loaded** — 14 rows, for one pilot case.
  Absent codes read as `no_map`, which is correct, not an error.
- Steps 8–12 (request-for-service wires, Charlson, prevalence table, crisis
  export) are **not built**. They are PDHC's roadmap, not part of the
  demonstrator.
