# termbank.pdhc — Technical description

Bulk canonical terminology store for the PDHC platform. Read-only over
HTTP; mutations happen only through the import CLIs. This document
describes the service as it is deployed today (read API + seven
importers, FHIR conformance hardened under rollups #347 / #374).

## Role in PDHC

`termbank.pdhc` imports upstream vocabularies as-is and serves them
read-only. It **owns the canonical URI namespace**
`https://termbank.pdhc.se/CodeSystem/<system>/<code>` (Rule 27). No
other service mints canonical URIs: `plan.pdhc` mirrors the subset it
curates into its working set, `rosetta.pdhc` stores mappings that point
at these URIs, and downstream data services (`cdr.pdhc`, `forms.pdhc`,
`1177.pdhc`, `request.pdhc`) reference them.

Imported vocabularies (seven importers, one `system` each):

| `system`          | Importer script       | Source |
|-------------------|-----------------------|--------|
| `loinc`           | `scripts/import_loinc.py`   | LOINC release table |
| `snomed`          | `scripts/import_snomed.py`  | SNOMED CT Managed Service SE (RF2) |
| `icd10`           | `scripts/import_icd10.py`   | ICD-10-SE |
| `atc`             | `scripts/import_atc.py`     | ATC (Läkemedelsverket / SeNSL) |
| `kva`             | `scripts/import_kva.py`     | KVA medical (KMA) + surgical (KKA) procedures |
| `socialstyrelsen` | `scripts/import_sosstyr.py` | Socialstyrelsen termbank (full XML) |
| `icf`             | `scripts/import_icf.py`     | ICF |

There is no ICD-11 importer.

## FHIR conformance scope

Deliberately narrow (Rule 15): termbank implements **CodeSystem
`$lookup`** plus a `/fhir/metadata` CapabilityStatement, and nothing
else FHIR-shaped. ValueSet + `$expand` / `$validate-code`, ConceptMap +
`$translate`, and TerminologyCapabilities are **out of scope by design**
— that curated terminology layer lives in `plan.pdhc`. See
`docs/decisions/ADR-001-scope-rule15.md`.

## Data model

Five tables (`app/models/__init__.py`):

- **`concepts`** — primary canonical row. Primary key is
  `canonical_uri` itself (no numeric surrogate leaks out; Rule 27 /
  Rule 18). Columns include `system`, `code` (unique together via
  `uq_concept_system_code`), `display`, provenance (`source`,
  `source_url`, `version`), lifecycle (`status` ∈
  `active|retired|experimental`, `imported_at`, `retired_at`),
  `parent_uri` (hierarchy, null for flat vocabularies), and an
  `import_run_id` FK.
- **`concept_designations`** — localised displays. `(concept_uri,
  language, value)` unique; `use_code` (e.g. `preferred`, `fsn`,
  `synonym`) drives `displayLanguage` selection.
- **`concept_properties`** — source-specific attributes. `key` / `value`
  / `value_type` ∈ `string|code|integer|decimal|boolean|dateTime`;
  `value_type` maps to the right FHIR `value[x]` field on output.
- **`import_runs`** — per-import bookkeeping (source, version,
  source_url, timings, `status` ∈ `running|completed|failed|cancelled`,
  `concepts_imported` / `concepts_failed`, operator, notes).
- **`audit_log`** — Rule 24 operation log (actor, action, target,
  request_id, JSON details).

`ConceptDesignation` and `ConceptProperty` cascade-delete with their
parent concept.

## HTTP API

Routes live in `app/api/terminology.py`; the app factory and read-only
guard are in `app/__init__.py`.

### `GET /fhir/metadata`
FHIR R5 CapabilityStatement. Advertises CodeSystem `$lookup` as the
headline operation, plus the shortcut/auxiliary routes and the
`termbank-search` OperationDefinition. `CapabilityStatement.date` is
derived from the module file's mtime (not `datetime.now()`) so it is
identical across gunicorn workers and only advances on an image rebuild
(finding §1.4 / #352).

### `GET|POST /fhir/CodeSystem/$lookup` (and `/fhir/CodeSystem/%24lookup`)
The primary operation. Parameters:
- `system` (required), `code` (required)
- `displayLanguage` (optional)

On `GET` these are query params; on `POST` they come from a FHIR
`Parameters` request body (`_extract_from_parameters_body`, accepting
`valueUri` / `valueUrl` / `valueCode` / `valueString`). The `%24`
(URL-escaped `$`) path variant is registered for clients that emit it.
Missing `system`/`code` → `400` OperationOutcome; no match → `404`.
Success → a FHIR `Parameters` resource.

### `GET /CodeSystem/<system>/<code>`
Convenience path equivalent to `$lookup`. `<code>` is captured with a
`<path:...>` converter so codes containing `/` work. Supports
`displayLanguage`. Same Parameters response / 404 behaviour.

### `GET /Concept?uri=<canonical_uri>`
Lookup by canonical URI. The URI is a **query parameter** (`?uri=...`),
not a path segment. Missing `uri` → `400`; no match → `404`. Supports
`displayLanguage`. Same Parameters response.

### `GET /search?q=&system=&limit=&displayLanguage=`
Fuzzy, ranked search across `Concept.display` and designation values in
a single query. `q` required (empty → empty result set). `system`
repeatable or comma-separated. `limit` default 20, clamped to 1..200.
Rank tiers: exact code/display/designation match (1) → display prefix
(2) → preferred-designation prefix (3) → display/designation contains
(4) → designation contains (5); ties broken by active status, then
display length, then canonical URI. Response is plain JSON
(`{query, systems, system, count, results[]}`), **not** a FHIR Bundle.

### `GET /health`
DB reachability plus a per-source version/staleness summary. Returns
`200` / `status: "ok"` when `SELECT 1` succeeds, `503` /
`status: "degraded"` otherwise (CLAUDE.md §10 shape). Each source row
carries `version`, `concept_count`, the pinned `<SOURCE>_VERSION` env
value, and a `stale` flag when the pin and the loaded version differ.
`version` reports `TERMBANK_GIT_SHA` (default `dev`).

### Response shapes
- `$lookup` / `/CodeSystem/<system>/<code>` / `/Concept` → FHIR R5
  `Parameters`: `name` (= system), `version`, `display`, then
  `property` parameters for `code`, `status`, optional `parent` /
  `deprecationDate`, and termbank-scoped `canonical-uri`, `source`,
  `source-url`, followed by `designation` and per-property parameters.
  Concept-level fields are surfaced as spec `property` parameters, not a
  non-standard `_meta` envelope (#351).
- Errors → FHIR `OperationOutcome`.

### Read-only enforcement (Rule 6)
A `before_request` hook returns `405` (OperationOutcome, code
`not-supported`) for any `POST`/`PUT`/`PATCH`/`DELETE`, **except** the
two `$lookup` POST endpoints (`/fhir/CodeSystem/$lookup` and
`/fhir/CodeSystem/%24lookup`), which are reads with no side effects.
`GET`/`HEAD`/`OPTIONS` always pass.

## Import CLIs

Each importer parses upstream, builds concepts + designations +
properties, and writes them through the shared `importer_base.py`
upsert. **Every importer requires `--source-url` and `--version`**;
`--operator` and `--batch-size` (default 2000) are optional everywhere.
File-input flags differ by source:

| Importer | Required file flag(s) |
|----------|-----------------------|
| `import_loinc.py`   | `--local-file` |
| `import_icd10.py`   | `--local-file` |
| `import_atc.py`     | `--local-file` |
| `import_icf.py`     | `--local-file` |
| `import_snomed.py`  | `--release-dir` (RF2 release folder) |
| `import_kva.py`     | `--kma-file` and/or `--kka-file` (at least one) |
| `import_sosstyr.py` | `--local-file` optional (downloads from `--source-url` otherwise) |

There are no `--release-csv` / `--release-tsv` flags. Idempotency
(Rule 27): re-running with an already-imported `--version` is a no-op; a
new `--version` updates concepts in place while preserving each
`canonical_uri`.

Example invocations are in `docs/user_manual.md`.

## Configuration & deployment

- **Ports**: 9010 reserved · 9011 PostgreSQL (container 5432 mapped to
  `127.0.0.1:9011`) · 9012 Flask app · 9013 reserved.
- **Containers** (`docker-compose.yml`): `termbank_pdhc_db`
  (`postgres:16`, volume `termbank_pdhc_pgdata`) and `termbank_pdhc_app`
  (built from `Dockerfile`). Pin `COMPOSE_PROJECT_NAME=termbank_pdhc` so
  release swaps don't orphan the data volume.
- **Config** (`app/config.py`, `.env.example`): `DATABASE_URL`
  (default `postgresql+psycopg2://termbank:changeme@localhost:9011/termbank_pdhc_db`),
  `CANONICAL_URI_BASE` (default `https://termbank.pdhc.se`), `SECRET_KEY`,
  and the per-source `<SOURCE>_VERSION` pins consumed by `/health`.
- **AUTH**: `AUTH_MODE=off` — the read API is public (Rule 24), so there
  is no SSO gating on this service.
- **SECRET_KEY guard** (#354): startup refuses the placeholder
  `SECRET_KEY` in production; pytest is exempt via `TESTING=True`.
- **Migrations**: Alembic under `migrations/` (`flask db upgrade`).

## Tests

`app/tests/` covers the read API, health, models, per-importer parsing,
and a `test_capability_truth.py` conformance check that keeps the
CapabilityStatement honest against the routes actually registered. The
Postgres-only search ranking (`func.least`) is deselected on SQLite via
`make test` (`-k "not search"`); `make test-all` runs everything against
Postgres. `make corpus` / `make conformance` emit and validate `$lookup`
+ CapabilityStatement samples against the HL7 R5 validator.

## Port Allocation

All ports bind to `127.0.0.1` (loopback only); external traffic arrives
via the reverse proxy.

| Port | Service |
|------|---------|
| 9012 | Flask application (Gunicorn) |
| 9011 | PostgreSQL database |
