All documentation Download (Markdown) Technical

Rosetta · rosetta.pdhc.se

Technical manual


rosetta.pdhc — Technical specification

Rosetta re-represents patient observations sourced from gateway.pdhc into
three clinical-data standards — FHIR R5, openEHR (FLAT/simSDT), and
OMOP CDM — serves them over a web UI and a JSON REST API, and can
deliver the openEHR representation onward to an external openEHR CDR. It is
an analysis-phase service: SSO-gated, org-scoped, consent-enforced
(fail-closed), and audited on every read.

1. Data flow

SSO user ─Refresh─► gateway.pdhc GET /api/v1/observations?organization=<org>
                          │ (FHIR R5 bundle; canonical typed value in an extension)
                          ▼
                   observation_cache  ──► fhir_converter    ─► fhir_representations
                                       ├─► openehr_converter ─► openehr_representations (FLAT)
                                       └─► omop_converter     ─► omop_measurements
                          │
                          ├─► web UI (3 side-by-side cards)
                          ├─► REST /api/v1/patient/<guid>/{fhir|openehr|omop}
                          └─► openehr_delivery ─► external openEHR CDR (flag-gated write)

Concept resolution is delegated to plan.pdhc: observations arrive from
gateway already FHIR-coded; Rosetta passes concept_guid through and records
concept_name, linking back via
https://plan.pdhc.se/api/v1/concepts/<concept_guid>. No local terminology.

Canonical-read (#501)

gateway_client.normalise prefers the canonical typed observation block
carried on the gateway resource as the extension
urn:pdhc:fhir:extension:canonical-observation (valueString = JSON) over the
lossy FHIR value[x]. When present it takes concept_guid, the typed value,
unit, and effective_at from that block; only a genuine numeric lands in
observation_cache.value (Float), while the full typed value survives in
raw. A categorical/boolean therefore no longer silently becomes a number.
When the extension is absent it falls back to code.coding[0].code +
valueQuantity + effectiveDateTime. Every onward call also carries the X2
session header (§6).

2. HTTP API

All under the /api/v1 prefix (blueprint api).

Method Path Auth Returns
GET /patient/<guid>/fhir SSO FHIR searchset Bundle {resourceType, type:"searchset", timestamp, total, entry:[{resource}]}
GET /patient/<guid>/openehr SSO {patient_guid, total, format:"flat", compositions:[{template_id, flat}]}
GET /patient/<guid>/omop SSO {patient_guid, total, measurements:[{measurement_concept_id, measurement_date, measurement_datetime, value_as_number, unit_source_value, measurement_source_value}]}
POST /openehr/realisable service key realisability.check_plandef(...) rollup (#523) — see §3a
POST /openehr/template-spec service key template_synthesiser.synthesise(...) manifest (#524) — see §3a

The three GET /patient/... reads are consent-gated (check_patient_allowed,

422), audited (x1_read_audit, #407), and abort(404) when the patient has

no rows of that representation.

HTML views (blueprint views): GET / (patient list, org-scoped, auto-refresh
if cache >5 min stale), GET /patient/<guid> (3-card detail; auto-converts all
three if missing), POST /patient/<guid>/convert (idempotent re-convert),
POST /refresh (sync cache from gateway).

Auth (blueprint auth, /auth): /login, /callback, /logout,
/logged-out.

Public (no SSO login redirect — app/auth.py:_public_path): /auth/*,
/healthz, /metadata, /static/*, and the two /api/v1/openehr/*
modelling endpoints
(they carry no patient data; guarded by service key
instead — §3a).

3. The three converters (app/services/)

FHIR R5fhir_converter.to_fhir_r5(obs) / convert_patient_fhir(guid):
prefers the rich FHIR from gateway's raw JSON (basedOn / performer /
referenceRange / extensions preserved); normalises status=final,
subject=Patient/<guid>, category=laboratory, meta.profile includes the
IPS profile; code system
https://plan.pdhc.se/api/v1/concepts/<concept_guid> + concept_name
display; valueQuantity with UCUM unit. → fhir_representations.

openEHR (FLAT)openehr_converter.convert_patient_openehr(guid)
flat_emitter.emit_flat_compositions(rows). This is the live path:

Deprecated (#509): openehr_converter.to_openehr_composition(obs) — the
old hardcoded nested openEHR-EHR-COMPOSITION.report-result.v1 /
...OBSERVATION.laboratory_test_result.v1 builder — is no longer called
from any path. It is retained only until CLIP #509 deletes it; do not build
on it.

OMOP CDMomop_converter.to_omop_measurement(obs) /
convert_patient_omop(guid): measurement domain — person_id←patient_guid,
measurement_concept_id←concept_guid,
measurement_date/datetime←observed_at, value_as_number←value (categorical →
value_as_concept_id), unit_source_value←unit,
measurement_source_value←concept_name, measurement_source_url←plan.pdhc concept URL. → omop_measurements.

Conversion is idempotent: re-convert deletes and rebuilds all three per
patient; runs are tracked in conversion_log.

3a. openEHR modelling endpoints (plan.pdhc integration)

Both are pure over the #503 registry — no DB, no patient data, no network —
and service-key guarded (§4, X-Service-Key = ROSETTA_SERVICE_KEY). They
accept {"concept_guids":[...]} or a plandef-shaped body (transactions /
goals carrying concept_guid), plus an optional concept_names map.

4. openEHR delivery (openehr_delivery.py, #505/#506/#511) — the write path

Rosetta's first write path: it can file the FLAT compositions into an
external openEHR CDR. The machinery is transport-agnostic; the wire protocol
lives in openehr_transports.py.

Transports (openehr_transports.py, selected by
OPENEHR_DELIVERY_TRANSPORT, built by build_transport()):

5. Data model (app/models/__init__.py, PostgreSQL)

Table Purpose
users Local user mirror (FK + audit, Rule 24)
observation_cache Raw gateway observations: source_obs_guid (unique), patient_guid, org_guid, concept_guid, concept_name, value (Float), unit, observed_at, raw (JSON)
fhir_representations Validated FHIR R5 Observation (resource_json, FK → cache)
openehr_representations FLAT openEHR composition (template_id, archetype_id, composition_json); observation_cache_guid nullable — a FLAT composition may span several observations (#504)
omop_measurements OMOP measurement row (person_id, measurement_*, measurement_source_url)
conversion_log Per-patient conversion runs (status, fhir/openehr/omop counts)
refresh_log Gateway sync runs (rows_fetched, status)
audit_log Generic app audit events (conversion.auto/manual, etc.)
patient_ehr (#505) One EHR per patient in the target CDR: patient_guid (unique), ehr_id, namespace. String(36) guids (sqlite-portable for tests)
openehr_delivery (#506) Per-composition delivery log: patient_guid, template_id, dedup_key (unique), status (pending|delivered|failed), ehr_id, composition_id, attempt_count, last_error, last_status, payload (FLAT JSON, for retry), created_at/updated_at

The X1 read audit (#407, §6) is written by x1_audit; it records the
person_guid, role_guid, purpose, access_basis, route, n_rows, session_id
tuple for the kontrollör trail.

Migrations (head chain, app/migrations/versions/):
ad445ccc0480 (initial schema) → b7f2a1c3d901 (add
omop_measurements.measurement_source_url) → c8a1d2e3f4a5
(openehr_representations.template_id for FLAT) → d9e0f1a2b3c4 (patient_ehr
identity, #505) → e0a1b2c3d4e5 (openehr_delivery, #506). Single head:
e0a1b2c3d4e5.

Auth (app/auth.py)

Consent (#422), fail-closedanalysis_consent.check_patient_allowed()
calls ips.pdhc POST /api/v1/patients/analysis-filter (contract in
plans/pdhc_data_shapes.md §5). Purpose is derived from the active role:
research → research (+ research_project_guids); quality/registry →
quality_registry; other clinical → statistics; SU-admin-no-affiliation →
administration. No verdict → HTTP 503, no data. Applied to every patient
read (views + API).

X1 read audit (#407)x1_audit writes a row per read:
person_guid, role_guid, purpose, access_basis, route, n_rows, session_id.

X2 session propagation (#408)session_headers.outbound_session_headers
adds X-Operator-Session-Id (from a forwarded header, else the SSO blob
session_id / JWT sid) to every onward gateway/ips call, for end-to-end
kontrollör audit.

7. Configuration (.env)

FLASK_APP=app:create_app
APP_PORT=9092
DB_HOST / DB_PORT=9091 / DB_NAME=rosetta_pdhc_db / DB_USER / DB_PASSWORD
DATABASE_URL=postgresql+psycopg2://…:9091/rosetta_pdhc_db
AUTH_MODE=off|sso
SSO_BASE_URL=https://sso.pdhc.se   SSO_CLIENT_ID / SSO_CLIENT_SECRET
SSO_CALLBACK_URL=https://rosetta.pdhc.se/auth/callback
GATEWAY_BASE_URL=https://gateway.pdhc.se
IPS_BASE_URL=https://ips.pdhc.se       # #422 consent; fail-closed if unset
SECRET_KEY / DEFAULT_ORG_GUIDS

# #523/#524 — modelling endpoints: shared secret with plan.pdhc.
# Blank => endpoints OPEN (dev); set => X-Service-Key required (prod).
ROSETTA_SERVICE_KEY=

# #505/#506 — openEHR delivery (the only write path).
OPENEHR_SUBJECT_NAMESPACE=urn:pdhc:patient-guid   # contractual EHR subject namespace
OPENEHR_DELIVERY_ENABLED=                          # master switch, OFF by default (no-op until truthy)
OPENEHR_DELIVERY_TRANSPORT=asha                    # asha | spec_rest
OEHR_BASE=https://openehr.phanera.se               # target CDR base URL (sandbox)
OEHR_INSTANCE=cdr1                                  # target instance
OEHR_USER= / OEHR_PASS=                             # ASHA form-login creds (operator-held)
OEHR_TOKEN=                                         # reserved for the future spec_rest transport

8. Deployment

Single db service via docker-compose (postgres:16); app runs under gunicorn
via ./start.sh:

openEHR delivery is a runtime capability, not a separate process: it is off
until OPENEHR_DELIVERY_ENABLED is set and the OEHR_* target/creds are
configured. Never point delivery at a production CDR — the only wired
target is the Phanera sandbox.

9. Tests (app/tests/)

test_scaffold (/healthz, /metadata), test_converters (FHIR + OMOP),
test_flat_emitter (FLAT grouping / UCUM / unmapped), test_gateway_client_canonical
(#501 canonical-block read), test_realisability (#523), test_template_synthesiser
(#524), test_openehr_identity (#505 resolve-or-create), test_openehr_delivery
(#506 deliver / dedup / backoff / rejection), test_sandbox_roundtrip
(FLAT round-trip), test_analysis_consent (purpose derivation + ips filter,

422), test_reform_scope (Zone-1 org scoping, #417), test_session_propagation

(X-Operator-Session-Id, #408), test_x1_audit (audit tuple, #407),
test_concept_map (#503 registry).

Port Allocation

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

Port Service
9092 Flask application (Gunicorn)
9091 PostgreSQL database