Technical manual
Audience: integrators and operators. Grounded in the code under
planp/ (Flask app factory app/__init__.py, app/config.py, the
app/api/* blueprints, app/models/*, and migrations/). For the
authoring/product overview see user_manual.md; for endpoint payload
detail see api_reference.md.
plan.pdhc is the PlanDefinition authoring service of the PDHC platform: a
Flask + SQLAlchemy + Postgres app that lets authors create clinical Concepts,
PlanDefinitions (goals + activities + transactions), Questionnaires/Forms,
and ValueSets, and publishes all of it as a conformant FHIR R5 terminology
service. It also hosts an opt-in, feature-flagged guided-authoring assistant
(epic #516).
It is a Flask app built by create_app() in app/__init__.py (blueprints
registered there), configured by app/config.py, migrated by Flask-Migrate
(Alembic) under migrations/versions/.
9030 (gunicorn binds 127.0.0.1:9030)9031 (Postgres, loopback-only 127.0.0.1:9031:5432)pdhc_app (Flask/gunicorn) and pdhc_db (postgres:16), perdocker-compose.ymlpdhc_gateway — a legacy name retained for dataGET /api/health → {status, database, service, version},degraded. Rate-limitAccess-Control-Allow-Origin: https://www.pdhc.se soservices.html can read the real DB dot.All API blueprints are mounted under /api/v1. Web (server-rendered) blueprints
are mounted at root.
Confirmed from app/models/* and migrations/. GUID rule (Rule 18): every
entity has a guid (UUID v4); all cross-references are by GUID, never
integer id.
Core terminology / concepts (concept_models.py)
- concepts — the central resource: response_type, unit, valueset,
canonical_lib + canonical_refnumber (terminology binding), range_low/high,
anchor_*, status (default draft). One CHECK constraint ck_concept_range
(range_low ≤ range_high).
- values_catalog — reusable coded values.
- valuesets — bound answer sets.
- valueset_values — valueset↔value membership (with sort_order; unique
(valueset_guid, value_guid)).
Lookup tables (concept_models.py) — author-editable, GUID-keyed:
- canonical_libs, concept_types, response_types, units, plandef_types,
intended_uses.
PlanDefinition (fhir_models.py + activity_models.py)
- plan_definitions — FHIR R5 PlanDefinition (has fhir_id, fhir_data JSON,
plus goal/action JSON columns).
- plandefinition_goals — goal rows (concept ref + target range/operator/value).
- activities, plandefinition_activities (join), transactions — the data
points an activity collects (concept_guid + optional unit, ranges).
Forms / Questionnaires (forms_models.py)
- questionnaires, questionnaire_items, questionnaire_responses,
form_definitions, form_definition_items.
Auth (user_models.py)
- users — local bootstrap/superuser row (used by AUTH_DISABLED auto-login and
first-run SU bootstrap).
That is 21 tables. concept_guid is the universal anchor: one concept GUID
resolves canonical lib, concept type, response type, unit, and value set — none
of which is duplicated into a plan (see db_schema_snapshot.md).
The canonical measurement unit is stored on concepts.unit. Downstream code and
plans resolve the unit via the concept, not via Transaction.unit /
Goal.target_unit (those free-string columns exist but are duplicates). The
validator flags divergence with W-UNIT-CONTRADICTS. Do not treat a
transaction-level unit as authoritative.
/api/v1)Server-rendered web routes exist for all of these; the JSON API is what
integrators use.
app/api/concepts.py): GET/POST /concepts,GET/PUT/DELETE /concepts/<guid>, POST /concepts/import,GET/POST /concepts/<guid>/values, DELETE /concepts/<guid>/values/<value_guid>.app/api/lookup_tables.py, prefix/api/v1/lookup): full CRUD for /values and /valuesets, plus valueset/valuesets/<guid>/values[/<value_guid>].app/api/plandefinitions.py): GET/POST /plandefinitions,GET/PUT/DELETE /plandefinitions/<guid>.app/api/forms.py): /forms, /forms/produce,/forms/<guid>/questionnaire, /forms/<guid>/publish (publish → immutable),/forms/<guid>/render-ready, /forms/<guid>/immutability, /forms/<guid>/versions.app/api/form_definitions.py): CRUD under/form-definitions and /form-definitions/<guid>/items.app/services/save_guard.py)Concept/plandef saves run the deterministic validators and block on
error-severity issues (warnings pass). Gated by PLANDEF_VALIDATION_ENFORCED
(default on; kill-switch). An admin/SU may force with
override_validation=1 (JSON key or form field), which is written to the app log
as an audit trail. On a block, the web route flashes and the JSON API returns
HTTP 422 with the structured issues (SaveBlocked.as_dict()).
/api/v1) — LIVEAdditive to the CRUD JSON (does not replace it). Canonical URLs are
https://plan.pdhc.se/fhir/{Resource}/{id} (a FHIR identifier — routes stay under
/api/v1). $-operations are also registered under the %24-escaped path for
clients that encode $.
ValueSet (app/api/fhir_valueset.py)
- GET /ValueSet, GET /ValueSet/<guid>
- GET /ValueSet/<guid>/$expand, POST /ValueSet/$expand
- GET /ValueSet/<guid>/$validate-code, POST /ValueSet/$validate-code
CodeSystem — local id plan-pdhc-local (app/api/fhir_codesystem.py)
- GET /CodeSystem, GET /CodeSystem/<id>
- GET/POST /CodeSystem/$lookup
- The local CodeSystem code is the Concept.guid (ADR D1). Each entry
carries the concept's canonical-lib / canonical-ref / status as
properties; external systems delegate lookups to termbank.
ConceptMap — local id plan-pdhc-canonical-bindings
(app/api/fhir_conceptmap.py)
- GET /ConceptMap, GET /ConceptMap/<id>
- GET/POST /ConceptMap/$translate — translate local concept ↔ external canonical
code (LOINC/SNOMED/…).
Terminology surface (app/api/terminology.py): a $validate-code entry point
plus termbank proxies (/termbank/concept/<system>/<code>, /termbank/search).
FHIR PlanDefinition read (app/api/fhir_plandefinitions.py):
GET /PlanDefinition, GET /PlanDefinition/<fhir_id>,
GET /PlanDefinition/<fhir_id>/$expand.
Blueprint app/api/authoring.py, prefix /api/v1. All endpoints are
@requires_role('read_write') and none of them mutate data. Gated by
AUTHORING_ASSISTANT_ENABLED (default false); when off every endpoint
returns {"enabled": false} and existing save paths are untouched.
GET /authoring/models — allowlisted Claude models + whether a key is set.POST /authoring/validate — run Layer-1 validators on a concept or plandefPOST /authoring/assist — Layer-2 Claude suggestion for a plain-languageintent, self-checked against Layer 1. Requires an intent; degrades toPOST /authoring/openehr-realisable — proxy to rosetta.pdhc's realisabilityavailable:false + reason) when rosetta isTwo layers (design doc plandef_authoring_assistant_design.md):
- Layer 1 — app/services/plandef_validation.py: pure, deterministic,
shared by web + API. Invariant codes include E-UNIT-REQUIRED,
E-VALUESET-REQUIRED, E-RESPONSE-TYPE-UNKNOWN, E-DANGLING-REF,
E-RANGE-INVERTED (errors) and W-TERM-MISSING, W-TERM-UNVERIFIED,
W-UNIT-CONTRADICTS, W-EMPTY-PLANDEF (warnings). This is the actual safety
floor and is used by GA-5 (§3).
- Layer 2 — app/services/plandef_assistant.py: Anthropic Messages API call.
Model is caller-selected from AUTHORING_ASSISTANT_MODELS (default
claude-sonnet-5, allowlist also claude-opus-4-8, claude-haiku-4-5-*); a
non-allowlisted model is rejected. Searches existing concepts/termbank first,
runs Layer 1 on its own proposal, never auto-commits, never raises. No PHI is
sent — only concept/terminology metadata + the author's intent.
MDR note: PlanDef thresholds flow to request.pdhc's data-driven alerts, so Layer
1 is the reviewed, deterministic guarantee; the assistant is a reviewed-draft
helper on top of it.
Two paths, both in app/api/auth.py.
/api/v1/auth/{login,callback,logout,me}. Onsso.pdhc /api/auth/me/serviceapp/services/sso_service.validate_token). session['sso_user'] is amust_change_password) → 403 with achange_password_url (API) or redirect to SSO change-password (web).requires_role), levels read_only(1) < read_write(2) <read_only — any authenticated session.read_write — user_type == "professional" and "planning" in theadmin — is_su_admin._phases() prefers the reform-canonical session_phaseseffective_phases for pre-reformplanning gate value is identical in both.is_su_admin short-circuits all role checks.AUTH_DISABLED=true bypasses auth entirely and auto-logs-in theadmin user. Never ship this to prod.X-Source-Service + X-Service-Key headers let a recognised sibling call
POST/PUT/DELETE without an SSO session (_service_key_outcome()). Recognised
sources (KNOWN_SERVICES):
- loader.pdhc → env PLAN_LOADER_SERVICE_KEY (bulk concept loader)
- sim.pdhc → env SIM_PDHC_SERVICE_KEY (concept-GUID resolution at run time)
An unknown source, missing key, or wrong key returns 403. Valid-shape
service-key callers also bypass the rate limiter (@limiter.request_filter in
app/__init__.py) so canonicaliser warmup bursts don't trip the default
200/minute.
app/config.py / .env)Key env vars (see .env.example):
| Var | Purpose |
|---|---|
DATABASE_URL |
Postgres DSN (default DB pdhc_gateway on :9031) |
AUTH_DISABLED |
true = bypass auth (local dev only); prod sets false |
SSO_BASE_URL / SSO_CLIENT_ID / SSO_CLIENT_SECRET / SSO_CALLBACK_URL |
SSO integration |
PLAN_LOADER_SERVICE_KEY / SIM_PDHC_SERVICE_KEY |
trusted-sibling service keys |
AUTHORING_ASSISTANT_ENABLED |
master flag for /api/v1/authoring/* (default false) |
ANTHROPIC_API_KEY / ANTHROPIC_API_BASE |
Layer-2 assistant (no key ⇒ validation-only) |
AUTHORING_ASSISTANT_MODELS / _DEFAULT_MODEL / _MAX_TOKENS / _TIMEOUT_SECONDS |
model allowlist + bounds |
PLANDEF_VALIDATION_ENFORCED |
GA-5 fail-closed on save (default true; kill-switch) |
ROSETTA_BASE_URL / ROSETTA_SERVICE_KEY |
openEHR-realisable proxy |
CDR6_BASE_URL / CDR6_SERVICE_KEY / CDR_TRANSFER_TARGETS |
#530 Transfer page (thin proxy to cdr_6) |
RATELIMIT_DEFAULT (200/minute) / RATELIMIT_STORAGE_URI |
Flask-Limiter |
BOOTSTRAP_SU_USERNAME / BOOTSTRAP_SU_PASSWORD |
first-run SU bootstrap |
APP_VERSION |
reported by /api/health |
create_app() creates a superuser fromBOOTSTRAP_SU_USERNAME / BOOTSTRAP_SU_PASSWORD if no users exist (Rule 23).flask import-concepts <path.xlsx|.csv>
[--operator NAME] [--dry-run] [--json-out]. Parses, validates, and importsflask db upgrade (Alembic). Note: alembic_version isvarchar(32) — keep revision ids short.docker compose up -d --build (Dockerfiles COPY . ., so a plain/usr/local/www).curl -s https://plan.pdhc.se/api/health — expect 200 +"database":"connected".$expand, $validate-code, $lookup, $translate) — they reflectloader.pdhc / sim.pdhcTransaction.unit / Goal.target_unit as the unit of record —All ports bind to 127.0.0.1 (loopback only); external traffic arrives
via the reverse proxy.
| Port | Service |
|---|---|
| 9030 | Flask application (Gunicorn) |
| 9031 | PostgreSQL database |