Technical manual
Flask application with PostgreSQL (pgvector extension) running in Docker. Part of the PDHC microservice platform.
Ports: 9050 (Flask/gunicorn), 9051 (PostgreSQL)
| Table | Purpose |
|---|---|
inbound_observations |
Stores received observation data with composite key fields |
observation_vectors |
Resolved GUID chain context stored as vectors (experimental) |
guid_resolution_cache |
Cache for upstream GUID lookups with TTL |
validation_log |
Per-observation validation results |
audit_log |
GDPR-compliant audit trail |
| Service | Purpose |
|---|---|
PATValidationService |
Validates provider tokens via request.pdhc upstream |
GrantValidationService |
HMAC-SHA256 composite key validation |
ObservationValidator |
FHIR R5 observation schema validation |
ReportIngestionService |
Full validation chain + storage |
GuidResolutionService |
Resolves transaction → careplan → plandefinition chain |
VectorService |
Builds and stores resolved context vectors |
FeedService |
Proxies provider feed/download from request.pdhc |
PushService |
Push delivery to providers + receipt sending |
ReceiptService |
Receipt acknowledgement |
| Service | Port | Purpose |
|---|---|---|
| request.pdhc | 9060 | PAT validation, GUID chain resolution, feed/download proxy |
| plan.pdhc | 9030 | PlanDefinition repository (via request.pdhc proxy) |
| provider.pdhc | 9070 | Receipt delivery target |
DATABASE_URL=postgresql://gateway_user:password@localhost:9051/gateway_db
FLASK_SECRET_KEY=<random-64-char>
HMAC_SECRET=<random-64-char-separate-from-flask-secret>
REQUEST_SERVICE_URL=https://request.pdhc.se/api/v1
PROVIDER_SERVICE_URL=http://localhost:9070/api/v1
GUID_CACHE_TTL_SECONDS=3600
BOOTSTRAP_SU_API_KEY=<initial-superuser-key>
PUSH_TIMEOUT_SECONDS=30
PUSH_RETRY_COUNT=3
PGVECTOR_DIMENSIONS=384
| Route | Purpose |
|---|---|
/admin/cache |
Cache stats by type (total/fresh/stale), per-type and global flush buttons, recent entries |
/admin/cache/flush |
POST — flush mode=stale or mode=all, optional source_type filter |
/admin/health-report |
Live probes of upstream services (request.pdhc, contract.pdhc, sso.pdhc, local DB) + error events from last 24h |
/pats |
PAT validation/rejection activity per provider org |
/audit |
Paginated GDPR audit trail |
/grants |
DataExchangeGrant validity tracking |
The guid_resolution_cache table stores upstream responses keyed by (source_guid, source_type) with a per-entry TTL. Flush options:
is_expired() is true (age > ttl_seconds). No disruption — these would be re-fetched on next access anyway.Neither flush requires a service restart. The cache is in Postgres, not in-process memory.
# Development
./start.sh
# Production restart
./safe_restart.sh
cd gateway_app
source venv/bin/activate
# Unit tests (excludes integration)
pytest tests/ -m "not integration"
# All tests including live service probes
pytest tests/ -m integration
# Full suite
pytest tests/ -v
A read returning rows about N patients can audit either per-query
(one row per call, cheap) or per-patient (one row per patient
touched, richer for kontroller — "did anyone read patient P's data").
PDL Ch 4 § 3 obliges every vårdgivare to be able to answer that
kontroller question. The right granularity per route is a deliberate
trade between answerability and audit-table volume.
| Route | Method | Patient identifiers in response? | Granularity | event_type |
Rationale |
|---|---|---|---|---|---|
/observations (normal scope) |
GET | Yes — FHIR Bundle with patient refs | per-query with patient_guids[] in payload |
observations.read |
High-volume analyse-phase polling. The per-query row carries the full sorted patient_guids list, so kontroller can answer "was P in any read by X?" with WHERE payload_snapshot->'patient_guids' ? '<guid>'. Storing the list once per query is ~36 bytes × n_patients vs. one full row per patient. |
/observations (admin off-org bypass) |
GET | Yes | per-patient | observations.admin_read |
Rare and high-stakes (PDL Ch 4 § 1 — admins are not exempt from need-to-know without an audit trail). One row per patient touched, each carrying the same justification text + correlation_id, so kontroller filters cheaply by patient. Volume cost is acceptable because this path is rare; the X-Admin-Justification gate (#220) caps frequency. |
/provider/feed |
GET | Metadata only (SR refs, no PHI) | per-query | provider.feed.polled |
Polled on a 30s cadence. PDL § 3 cares about reads of patient data; metadata polling that yields no PHI is the wrong granularity for per-patient rows. Snapshot carries since cursor + n_items + limit, enough to reconstruct what window the provider polled. |
/provider/download/<sr_guid> |
GET | Yes — full Bundle for a single SR | per-query (which IS per-patient: one SR == one patient) | bundle.downloaded |
The route is single-SR. Per-query and per-patient collapse to the same row. No change needed. |
/provider/report/<sr_guid> |
POST | Inbound (not a read) | per-row inside ReportIngestionService | report.accepted / report.rejected |
Write path. Not in this ticket's scope but noted for completeness. |
For the observations.read normal path (per-query with patient_guids
list), an operator can answer "show every read of patient P by any
user" with:
SELECT *
FROM audit_log
WHERE event_type = 'observations.read'
AND payload_snapshot->'patient_guids' ? '<patient-guid>'
ORDER BY created_at DESC;
For the admin-read path (per-patient rows), the equivalent is the
direct filter:
SELECT *
FROM audit_log
WHERE event_type = 'observations.admin_read'
AND payload_snapshot->>'patient_guid' = '<patient-guid>'
ORDER BY created_at DESC;
Both surface the same answer; the granularity choice changes only
which side pays the storage cost (writer-side row explosion vs.
reader-side JSONB index lookup).