All documentation Download (Markdown) Technical

Plan · plan.pdhc.se

Technical manual


plan.pdhc — 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.


1. What the service is

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/.

All API blueprints are mounted under /api/v1. Web (server-rendered) blueprints
are mounted at root.


2. Data model (~21 tables)

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 unit-lives-on-concept invariant

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.


3. Authoring / CRUD API (/api/v1)

Server-rendered web routes exist for all of these; the JSON API is what
integrators use.

Save enforcement — GA-5 fail-closed (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()).


4. FHIR R5 terminology profile (/api/v1) — LIVE

Additive 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.


5. Authoring assistant (epic #516) — opt-in

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.

Two 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.


6. Authentication & authorization

Two paths, both in app/api/auth.py.

SSO (interactive users)

Service key (trusted siblings)

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.


7. Configuration (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

8. CLI & operations


9. Notes for integrators

Port Allocation

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