Technical manual
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.
requests for upstream reads, stdlib urllib for127.0.0.1, behind the reverse proxy at https://rosetta.pdhc.se).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.
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).
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,
x1_read_audit, #407), and abort(404) when the patient hasno 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).
GET /healthz → {status:"ok"|"degraded", service:"rosetta.pdhc",
database:"connected"|"unavailable", auth_mode} with a real SELECT 1 DBok, 503/degraded); CORS Access-Control-Allow-Origin:
https://www.pdhc.se, Vary: Origin, Cache-Control: no-store.GET /metadata → FHIR R5 CapabilityStatement (fhirVersion 5.0.0). NOTE:documentation strings in the currentreport-result.v1 shape and areapp/services/)FHIR R5 — fhir_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:
{path: value} map keyedpdhc_vital_signs), on templatepdhc_vitals.v1, composition archetypeopenEHR-EHR-COMPOSITION.encounter.v1 (COMPOSITION_ARCHETYPE).(patient_guid, effective_at) — oneapp/services/concept_map.py,templates/pdhc_concept_map.json): each concept resolves to a FLATDV_QUANTITY → magnitude+UCUM, DV_PROPORTION →skipped_no_value); a concept with no binding is surfaced inEmitResult.unmapped, logged, and skipped — never coerced into a wrongopenehr_representations with observation_cache_guid=NULL (atemplate_id set.Deprecated (#509):
openehr_converter.to_openehr_composition(obs)— the
old hardcoded nestedopenEHR-EHR-COMPOSITION.report-result.v1/
...OBSERVATION.laboratory_test_result.v1builder — is no longer called
from any path. It is retained only until CLIP #509 deletes it; do not build
on it.
OMOP CDM — omop_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.
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.
POST /api/v1/openehr/realisable (#523) — realisability.check_plandef.realisable (mapped, UCUM present for quantities, known DV type),pending/blocked (archetype drafted but the .opt not authored yet, #502),unmapped. Returns counts, the templates needed, and a per-conceptPOST /api/v1/openehr/template-spec (#524) —template_synthesiser.synthesise. Answers "which operational templates doesready / partialto_author, plus unmapped. The one authored templatepdhc_vitals.v1) also exposes its FLAT contract (flat_root,composition_defaults). It does not generate the .opt binary (that isopenehr_delivery.py, #505/#506/#511) — the write pathRosetta'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.
OPENEHR_DELIVERY_ENABLED defaults off; while off,deliver() / process_pending() are hard no-ops (client can ship and bedeliver(patient_guid, template_id, flat, *, transport, dedup_key,
namespace) — one composition. Writes an openehr_delivery row, resolvesdedup_key: andelivered key short-circuits without re-hitting the CDR.process_pending(transport, limit, namespace) — re-drives not-yet-MAX_ATTEMPTS=5; exponential backoffBACKOFF_BASE_SECONDS(60) * 2**(attempt-1) since the last attempt.failed rowlast_error / last_status captured (#506 wants those bodies read);openehr_identity.resolve_or_create_ehrehr_id, creating the EHR via the transport'screate_ehr when absent and persisting (patient_guid, ehr_id, namespace)patient_ehr. The subject external_ref is{namespace: OPENEHR_SUBJECT_NAMESPACE, id:{value:<patient_guid>,
_type:GENERIC_ID}, type:PERSON} — namespace is contractual with theTransports (openehr_transports.py, selected by
OPENEHR_DELIVERY_TRANSPORT, built by build_transport()):
asha (default) — AshaTransport, Phanera's ASHA sandbox atopenehr.phanera.se (#512). ASP.NET form login (session cookie +Tools/Ehrs?handler=CreateEhrsTools/Compositions?handler=UploadComposition Razor handlers. Uploadspec_rest — SpecRestTransport, a deliberate stub for a real/rest/openehr/v1 CDR (NotImplementedError); wired so a spec-REST targetapp/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)
effective_phases=["analysis"].Authorization: Bearer; validated againstsso.pdhc.se/api/auth/me/service with SSO_CLIENT_ID/SSO_CLIENT_SECRET.has_analysis_access(): user_type=="professional" andsession_phases (legacy fallback effective_phases); SUaffiliations[].care_unit_guid (legacy fallback organization_ids) viascope_to_user_orgs(); admins unscoped and may seed refresh fromDEFAULT_ORG_GUIDS./api/v1/openehr/* modelling endpoints areX-Service-Key ==ROSETTA_SERVICE_KEY (_service_key_ok). Blank key ⇒ open (dev); set ⇒Consent (#422), fail-closed — analysis_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.
.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
Single db service via docker-compose (postgres:16); app runs under gunicorn
via ./start.sh:
.shared/), ensures Docker/DBflask db upgrade.127.0.0.1:9092; logs under .shared/logs/./healthz smoke (10×1 s) before declaring up.127.0.0.1:9092 atrosetta.pdhc.se.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.
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,
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).
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 |