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 — on bulk import
a blank canonical_lib binds the concept to the local library, while an
unrecognised non-blank value is rejected; see below), 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).
result: true actually means (#872)$validate-code answers two different questions depending on the canonical
library, and the response does not look different unless you read one field.
| library kind | result: true means |
basis |
|---|---|---|
| measurement vocabulary (the default) | a Concept or ValueCatalog row exists for this exact code |
the row, returned as display + ref_guid |
classification (whole_system_answer_space = true) |
the whole code system is adopted; this code was never checked individually | the library, returned as ref_via: CanonicalLib |
The second kind also carries whole_system: true and a message naming who
does verify per-code existence. If you need to know which of the two answers
you got, read whole_system — result: true alone does not distinguish them.
Why the second kind exists. An ICD-11 or ICD-10 diagnosis is chosen from
~85 000 codes at the point of care. plan holds no copy of such a classification,
so the per-code question has no meaningful answer here and asking it anyway
returns a false negative rather than a fact.
Check order, which matters: Concept → ValueCatalog → whole-system. The
flag is the fallback, so a code that does have a row still answers per code,
with its display and ref_guid — strictly more information than "the system is
adopted". Flipping the flag changes the answer only for a code with no row,
from false to true.
Flagged libraries (canonical_libs.whole_system_answer_space):
| library | flagged | since |
|---|---|---|
icd11-mms |
yes | 2026-10-21 (#857) |
icd10 |
yes | 2026-11-02 (#872) |
everything else (loinc, snomed, atc, local, …) |
no | — |
A measurement vocabulary must never be flagged: for LOINC the per-code
question is exactly the right one, and a flag there would turn every typo into
an accepted code.
Who checks existence instead. For icd11-mms, icd11.pdhc validates against
WHO's codeinfo at entry. For icd10 there is no equivalent entry-point
validator today — see #874.
Consumers should record which basis they got. cdr.pdhc does: it writes a row
to cdr_audit_whole_system_accept for every coding accepted on the whole-system
branch, the mirror of cdr_audit_plan_miss. Read together they answer "on what
grounds is this code in the repository?". Both capability surfaces
(/api/v1/metadata and /api/v1/capability-statement) state this rule, its
reason and the required action.
What this does NOT give you. The flag is per library, so it accepts any
code of that classification for any purpose, diagnosis or not. The scoped
path cannot narrow it either: scoped_validate_code enumerates values_catalog
rows, so a ValueSet can express "one of these twelve codes" but never "any code
of this classification". Per-release acceptance is also untouched — nothing here
stops a code being used after it stops being nationally accepted.
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~~ — retired 2026-10-05. It named
no repo and held a live, never-rotated key that bypassed SSO. The two bulk
loaders that used it are dead; mint a new identity under its own name if
bulk loading is needed again.
- 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 |
SIM_PDHC_SERVICE_KEY |
trusted-sibling service key (PLAN_LOADER_SERVICE_KEY retired 2026-10-05) |
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~~ |
REMOVED 2026-10-07 (#786 item 4) — the #530 Transfer page was retired along with its two blueprints, template and config. Moving data is not authoring; plan is a design workbench, and the route duplicated machinery owned by sim.pdhc and the CDRs. Tombstoned in config.py so a stale .env carrying these does not look meaningful. |
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).Bulk concept import (#134): flask import-concepts <path.xlsx|.csv>
[--operator NAME] [--dry-run] [--json-out]. Parses, validates, and imports
concepts; reports created/updated/rejected with per-row reasons; exit code 1 if
any row is rejected.
Blank canonical_lib = LOCAL (#786 item 2). An empty cell is read as
"this term is ours" and binds the concept to the local library via
ensure_local_lib. It used to raise, which made a spreadsheet of purely
local concepts — a clinic authoring its own — impossible to import without
typing local into every row.
An unrecognised non-blank value still raises, and the asymmetry is
deliberate: blank is a decision, snomd is a typo, and silently resolving a
typo to a local binding would attach a wrong terminology claim to clinical
data. canonical_lib and canonical_refnumber remain identity fields — a
row that changes either on an existing concept is reported as a conflict
rather than overwriting.
- Migrations: flask db upgrade (Alembic). Note: alembic_version is
varchar(32) — keep revision ids short.
- Docker: docker compose up -d --build (Dockerfiles COPY . ., so a plain
restart runs stale code — rebuild or verify). Build context is the repo root so
root-level docs get baked into the image (Colima virtiofs can't bind-mount
/usr/local/www).
- Health check: curl -s https://plan.pdhc.se/api/health — expect 200 +
"database":"connected".
$expand, $validate-code, $lookup, $translate) — they reflectsim.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 |