0041 — FHIR R4 read-only facade as the public data API
- Status: accepted
- Date: 2026-10-22
- Deciders: maintainers (@martinezsalmeron, @ZoliQua)
- Tags: interoperability, fhir, api, registry, compliance, architecture
Context
Issue #747. Third parties increasingly expect clinical data in HL7 FHIR rather than in our ApiResponse envelope: labs, radiology centres, hospitals, patient apps and integrators. National connectors already on paper need it too:
- Poland P1 sends medical events over FHIR (ADR 0033).
- Germany's ePA uses FHIR-based MIOs (ADR 0032).
In the EU, the EHDS Regulation (EU) 2025/327 is in force. Its preparatory work points to FHIR R4 plus the International Patient Summary (IPS) as the European exchange format. Its obligations for EHR systems phase in by data category: patient summaries and e-prescriptions first, then imaging, test results and discharge reports in 2031. Two things have not been checked against the Official Journal text (Art. 105) or the implementing acts yet: the exact application date for EHR systems, and whether a dental practice system falls under the "EHR system" definition. This ADR does not depend on either answer.
Today we have two HTTP surfaces:
/api/v1/**serves the Nuxt UI and the copilot. It is shaped for screens and carries business rules that have no 1:1 FHIR equivalent, for example plan-owned quote lines (#176/#177), budget states and Verifactu invariants./api/v1/integrations/public/is a token-authenticated, read-only public API (issue #65 §5). It offerspingand patient lookup by phone, email or national id.
Our clinical data is barely coded:
- The only standard code is the tooth number: FDI / ISO 3950 (
odontogram/models.py:50,budget,billing). - Allergies and medications are free-text strings (
patients_clinical/models.py:76,:106). medication_cataloghas no ATC code.- Treatments use clinic-defined catalog codes.
- Findings are odontogram states.
There is no mature, generally adopted dental FHIR implementation guide. The HL7 Dental Data Exchange IG is US-centric and built around referrals.
Decision
DentalPin exposes clinical data to third parties through a read-only FHIR R4 facade. Module-contributed providers, collected through a core registry, assemble it. This facade becomes the public data API, and the internal API stays private.
- Two APIs, one audience each.
/api/v1/**stays internal. It is not a public contract and keeps changing freely./api/v1/fhir/R4/**is the public, versioned contract.- The
integrations/publicread endpoints are frozen (no new endpoints). They are deprecated once FHIRPatientsearch byidentifier/telecomcovers lookup, and removed only after checking that no Zapier, Make or n8n integration depends on them. - Webhooks (REST Hooks) stay in
integrations. FHIR Subscriptions are out of scope.
- The provider contract lives in core. It follows the
get_tools()shape, as in ADR 0038.BaseModulegains an optionalget_fhir_providers()(default[]).- Core collects providers from active modules only (ADR 0020).
- Each module maps its own models and calls its own existing service methods. Providers duplicate no queries and contain no business logic, so the facade is a translation of shape.
- Contributor modules take no dependency on
fhir.
- A removable
fhirmodule serves the facade.- It owns the routes, search and paging, the
Bundleassembly and the access log. - It generates
CapabilityStatement(/metadata) from the registered providers, so it lists exactly what is installed. - Its only
dependsisintegrations, for token authentication.
- It owns the routes, search and paging, the
- R4 (4.0.1), JSON only. IPS, the EU profiles and US Core are all on R4. Do not support R5 or XML until a consumer needs them.
- Read-only. Supported interactions are
read,search-typeandPatient/$everything. Create, update and delete over FHIR would bypass the business rules and the billing invariants. Inbound FHIR, if ever needed, is an import format formigration_import, not a live write API. - Authentication reuses the integrations API tokens (
dp_…, clinic-scoped and rate-limited), with new scopes of the formfhir:<ResourceType>.read. SMART on FHIR (OAuth2 withpatient/*.rsscopes and launch context) gets its own ADR when a consumer needs it. - Tenancy.
- The token's
clinic_idscopes every query and every reference the facade resolves. - A resource from another clinic answers 404, never 403, so its existence does not leak.
- Our UUIDs are used as FHIR logical ids unchanged.
- The token's
- Minimal search.
- Supported parameters:
_id,_lastUpdated,_count,patient,dateandidentifier, plustelecomonPatient. - Paging is
Bundle.link[next]over the PK-tiebroken ordering from #724. - Not supported: chained search,
_include/_revincludeand_has. - An unsupported parameter answers 400 with an
OperationOutcome. It is never silently ignored, because that would return an over-broad result set.
- Supported parameters:
- Terminology: text first, codes optional.
- Providers emit
CodeableConcept.textfrom our free text and add acodingonly where we hold a real code (FDI today). - The output is valid FHIR but not IPS-conformant. The
CapabilityStatementsays so and claims no IPS profile. - Storing SNOMED CT, ATC or ICD-10 codes (as optional columns plus a mapping UI) is a separate ADR, because SNOMED CT is not free in every country and a self-hosted product cannot ship it blindly.
- The dental mapping is our own, documented profile: odontogram →
Condition/Procedure, withbodySitecarrying tooth and surface.
- Providers emit
- Every request is logged.
- The
fhirmodule writes one row per request: token, clinic, interaction, resource type and the patient ids whose data was returned. - No response bodies are stored.
- This is the minimum needed to answer "who read this patient's record" under GDPR Art. 15 and the logging expectations of EHDS.
- The
- Validation happens in tests, not at runtime. Providers return plain dicts.
fhir.resources(pydantic v2) is a test-only dependency, and the tests validate each provider's output against R4.
Consequences
Good
- A single public contract for third parties, in a standard they already speak. The overlapping
integrations/publicread API goes away instead of growing. - Module isolation holds.
fhirnever imports clinical modules, the clinical modules never depend onfhir, and uninstalling a module simply drops its resources from/metadata. - Country connectors (P1, ePA, HCDSNS…) can build on the same providers instead of each re-mapping Patient, Condition and Procedure.
- The GDPR portability export (ADR 0038) can emit a FHIR
BundleviaPatient/$everythinginstead of growing a second exporter. - No runtime dependency is added for an output-only surface.
Bad / accepted trade-offs
- Two APIs mean two shapes to keep in sync. This is mitigated by providers calling service methods and by the R4 validation tests, but a new field reaches FHIR only when someone maps it.
- Until coded terminology lands, the output is weak for semantic interoperability. It is good for display and integration, and not enough for IPS or EHDS conformance.
- Read-only means integrators still cannot push data in. That is deliberate, and it will be requested.
- Phase 1 has no SMART on FHIR, so patient-facing apps cannot use the facade directly. Only server-to-server integrations holding a clinic token can.
- The access log is a new table that grows with traffic. Retention follows
gdprRetentionPolicywhen that module is installed; otherwise rows are kept. fhirdepends onintegrationsfor token auth. If more consumers appear, token auth should move to core.
Alternatives considered
- Turn
/api/v1/**into FHIR — rejected.- Every screen would pay for FHIR's verbosity and lossy mapping.
- Business rules with no FHIR equivalent (budget states, plan-owned quote lines, Verifactu chains) would need extensions everywhere.
- The internal API would lose its freedom to change.
- Keep three APIs (internal,
integrations/public, FHIR) — rejected. That would mean two public contracts for the same data, each needing its own scopes, docs and deprecation story. - Mappings inside the
fhirmodule, withdependson every clinical module — rejected. It would need about 10depends, it would break on every uninstall, andfhirwould have to know every module's internals. This is the same argument ADR 0038 made against a registry insidegdpr. - Event-bus fan-out to assemble resources — rejected for the reason given in ADR 0038: a fan-out cannot prove completeness, and
$everythingmust be complete or fail. - R5 — rejected for now. Adoption is marginal, and IPS and the EU profiles are on R4.
fhir.resourcesmodels at runtime — rejected. They add a heavy dependency and per-request validation cost to an output-only surface, and validating in tests catches the same mistakes.- Code everything with SNOMED CT up front — deferred. It is blocked by licensing outside member countries, and by data we do not hold today.
Phasing
- Phase 1:
- Core registry and the
fhirmodule. CapabilityStatement,Organization,Location,Practitioner,PractitionerRole,Patient,RelatedPerson,Appointment,AllergyIntolerance,MedicationStatement.Patient/$everything, token scopes and the access log.integrations/publicread endpoints marked deprecated.
- Core registry and the
- Phase 2:
ConditionandProcedurewith tooth/surfacebodySite.- Periodontal
Observation. CarePlan/ServiceRequest,MedicationRequest,DocumentReference,ImagingStudy.- A written dental profile.
- Out of scope:
- Billing, payments, budgets and claims (country ADRs).
- Writes.
- FHIR Subscriptions.
- Certification.
- Separate ADRs: coded terminology (SNOMED/ATC/ICD-10 and licensing), and SMART on FHIR.
How to verify the rule still holds
rg "def get_fhir_providers" backend/app/core/plugins/base.pyresolves, and the registry lives underbackend/app/core/.rg '"fhir"' backend/app/modules/*/__init__.pyfinds no clinical module that listsfhirindepends.rg "from app.modules\.(?!integrations)" backend/app/modules/fhirfinds nothing (thefhirmodule imports no clinical module).- No route under
backend/app/modules/fhiruses POST, PUT, PATCH or DELETE (read-only). - A test validates every registered provider's output with
fhir.resourcesR4 models. - A test reads another clinic's resource and gets 404.
- A test sends an unsupported search parameter and gets a 400
OperationOutcome. - A test checks that every request writes an access-log row.
- No new endpoint appears under
integrations/public(frozen).
References
- Issue #747 (analysis and mapping table), issue #65 §5 (
integrations/public), #724 (PK tiebreak) - ADR 0020 (module live iff installed), ADR 0032 (Germany TI/ePA), ADR 0033 (Poland P1), ADR 0035 (US / ONC), ADR 0038 (subject export registry)
backend/app/core/plugins/base.py(get_tools())backend/app/core/agents/tools/registry.pybackend/app/modules/integrations/public.py(require_scope)backend/app/modules/integrations/triggers.py(SUPPORTED_TOKEN_SCOPES)backend/app/modules/odontogram/models.py:50(FDI)backend/app/modules/patients_clinical/models.py- HL7 FHIR R4 (4.0.1): https://hl7.org/fhir/R4/
- International Patient Summary IG: https://hl7.org/fhir/uv/ips/
- Regulation (EU) 2025/327 (EHDS): https://eur-lex.europa.eu/eli/reg/2025/327/oj