Binding form fields and answer options to SNOMED CT, LOINC and ICD-10 codes, and the clinician review workflow.
Clinical Terminology Bindings
Map form fields — and individual answer options — to standard clinical codes (SNOMED CT, LOINC, ICD-10), review them in the Dictionary panel, and store the bindings inside the form definition itself. Epic: issue #133.
Where bindings live
On the UI element, under the omf namespace:
options.omf.coding: OmfCoding[]— the field (the question).options.omf.optionCoding: { [enumCode]: OmfCoding[] }— per answer option, keyed by the stored code (same key space asoptionPoints/optionLabels).
OmfCoding is FHIR Coding shape plus provenance:
{ system, code, display?, source: 'ai'|'human', confidence?, verified }.
Chosen over a dataSchema keyword deliberately: Ajv runs strict in every engine (API validation, React, Angular), so a custom keyword would need registering in all of them, while the omf bag already flows through assembly, refine and both renderers untouched. Renderers ignore bindings entirely — codes never render on the form; the dictionary is their only UI.
Because bindings ride in the definition and every submission is pinned to its exact form version, submitted answers are codified data retroactively and forever — the basis for cross-form queries, FHIR export (#79) and registry reporting (#137).
Bindings are also the identity of a reading over time. When a form is
filled repeatedly (q2h vitals) the previous-value chip and the flowsheet line
readings up by system + code first, so a field that is renamed, moved to
another section, or captured on a different form altogether still forms one
series; an unbound field falls back to its data path and loses its history the
moment it moves. See ADR-005 and the
omf.history / omf.unit keys in Form Builder.
The Dictionary panel
On the form preview page, the side panel has two tabs: Refine with AI and
Dictionary. The dictionary lists every field (from form-core's
collectCodedItems, so exports and embeddings see exactly what the panel
shows), grouped by section, with per-option rows under enum controls:
- Approve — flips an unverified binding (amber, typically
source: 'ai') to verified (green). Provenance is kept: an approved AI suggestion stayssource: 'ai', verified: true. - Remove — deletes a binding.
- Previous values — on each section header, whether the fields in that section show the
patient's earlier readings while filling (Inline · Popover · Off), written as
omf.historyon the Group; on each field row, the same control showing the effective value ("Inherit (Inline)") with an override, and a Unit box (UCUM) for numeric fields. An amber warning marks a field whose previous values are on but whose binding is not verified — its history breaks the next time the field is renamed or moved. See ADR-006. - Add code — manual binding. For LOINC, a search box offers real codes
from the loaded table (name, synonym, or exact-code lookup); picking one
fills code + display. Stored as
source: 'human', verified: true. - Suggest codes — the retrieve-then-select AI pass (#135): for every
uncoded field, the local LOINC table produces candidates and the model may
only choose among them or decline — it can never invent a code; anything it
returns that was not offered is dropped. Candidate search uses the same
label the panel displays (UI label, else the dataSchema
title, else the property key — #156), so AI-generated forms, whose titles live only in the dataSchema, get real candidates. Suggestions land as amber unverified chips (with confidence) for approval, never overwrite existing bindings, and are skipped below a 0.5 confidence floor. Auditedform.coding.suggest, meteredcoding.suggest.
Loading LOINC
A curated starter set of 65 common observation codes ships as data
migrations (20260805110000_seed_terminology_starters, then
20260916090000_loinc_observation_starter; both idempotent ON CONFLICT DO NOTHING), so every environment — including production, where migrations run
on API boot — has working search, suggestions and history alignment for the
fields clinicians chart most: heart rate (several methods), respiratory rate,
temperature by site, blood pressure by position, oxygen saturation, FiO2 and O2
flow, weight/height/BMI/head and waist circumference, pain scores, Glasgow
Coma Scale and AVPU, urine output, point-of-care glucose, peak flow, ETCO2,
capillary refill, fetal heart rate, Apgar and Morse fall risk. The rows live in
apps/api/prisma/loinc-starter.json, which the dev seed loads; each code was
verified ACTIVE against LOINC 2.82 via the public FHIR terminology server, and
carries LOINC's own names plus the ward abbreviations searches use (SpO2, GCS,
AVPU, GRBS). It is still a subset.
For real coverage, download the official "LOINC Table File (CSV)" from https://loinc.org (free account; the license — which also bars redistributing the table — is accepted there) and load it:
cd apps/api && npx tsx scripts/import-loinc.ts /path/to/Loinc.csv
Re-running upserts, so new LOINC releases load over old ones. This material contains content from LOINC (https://loinc.org), © Regenstrief Institute, Inc. and the LOINC Committee, under https://loinc.org/license.
Against production: Cloud Run has no shell, but the database is directly
reachable (see docs/deployment/GCP-CLOUD-RUN.md). Run the import from a
workstation, pointing DATABASE_URL at the production connection string:
cd apps/api && DATABASE_URL='<production connection string>' \
npx tsx scripts/import-loinc.ts /path/to/Loinc.csv
The same pattern works for scripts/import-icd10.ts.
ICD-10 (#136)
Same pattern as LOINC with a public-domain source: download the CMS "ICD-10-CM Order File" from https://www.cms.gov/medicare/coding-billing/icd-10-codes and load it:
cd apps/api && npx tsx scripts/import-icd10.ts /path/to/icd10cm_order_2026.txt
A handful of common category codes (diabetes, hypertension, asthma, CKD, IHD, COPD) ship as starters via the same data migration as LOINC. ICD-10 search is available to every tenant once loaded — no licensing gate.
SNOMED CT and the licensing gate (#136)
SNOMED CT is member-country licensed, so it is DOUBLY gated, server-side:
- Operator: configure
SNOMED_FHIR_URL— a FHIR terminology server that hosts SNOMED (Snowstorm or Ontoserver, self-hosted with your national release, or a licensed hosted endpoint). Search usesValueSet/$expand?url=http://snomed.info/sct?fhir_vs&filter=..., which all of them support. A down server degrades to empty results, never errors. - Per tenant: set
snomedEnabled: truein the tenant'ssettingsJSON for organizations whose country/affiliate license covers them (India is a member country — the national license is free). Without it, SNOMED search and suggestions refuse for that tenant and the dictionary shows why.
GET /api/terminology/systems reports each system's availability + reason;
the dictionary's search UI reflects it verbatim.
What the suggestion pass codes with what
- Fields (questions) get LOINC candidates — observations, vitals, scores are LOINC's home turf.
- Enum answer options get SNOMED candidates (qualitative concepts) —
only when the tenant's SNOMED gate is open. Option suggestions write
optionCoding[<code>]and appear nested in the dictionary like any other option binding. - ICD-10 is manual-search only for now: diagnosis-shaped fields are a judgment call the reviewer makes with the search box.
Write path
PATCH /api/forms/:id/coding with { scope, optionCode?, coding[] } replaces
that target's binding list (empty clears it). Same immutability rule as
refine: drafts are edited in place, published versions fork a new draft — and the
published version stays what clinicians fill until that draft is published from
the preview page (the Publish button reappears while a draft is pending). Every
write is audited (form.coding.update) with the acting user and the
system|code|verified list.
Verification gate
verified is the clinical gate. Nothing downstream should treat an
unverified binding as authoritative; exports (#137) will carry the flag and
default to verified-only.