Program & Enrollment — How it works
Overview
The program engine models longitudinal care programs and a patient's journey through them: program
definitions with workflows and states, patient enrollment, and state transitions with history. It is
the platform substrate the first domain plug-in (publichealth) builds program content on.
Data model & ownership
| Table | Purpose |
|---|---|
program | A program definition; data_sensitivity; concept-backed outcomes. |
program_workflow | A workflow within a program. |
program_workflow_state | States of a workflow (initial/terminal). |
patient_program | A patient's enrollment (unique open enrollment per patient+program). |
patient_state | The patient's state history (one open state per workflow). |
program_encounter_type_mapping | M36-001 — declares that an encounter type counts as a programme's activity. Configuration, not patient data. |
Key rules & invariants
- Enrollment validates the patient (in-process via demographic) and the program (active, not voided); completion validates the outcome concept (including outcomes-set membership when configured).
- A state transition closes the current open state and opens the new one; a terminal state ends workflow participation; history is append-only (one open state per workflow via a partial unique index + close-before-open flush).
- Enrollment reads are access-scope/visibility filtered (
EnrollmentAccessGuard) with a PHI-free audit carrying the program'sDataSensitivity. - Encounter-type mappings are many-to-many (M36-001). One encounter type may serve several
programmes — an ANC visit counts for both an antenatal and a PMTCT programme — and one programme has
several encounter types. ⚠ A
UNIQUE (tenant, encounter_type)constraint would make "several" impossible rather than handled. facility_idis nullable on the mapping, and null means every facility in the tenant. Which journey a visit starts is a per-site operational decision; which encounter types belong to a programme is usually a tenant-wide clinical definition. A facility row overrides the tenant row, nearest scope winning. ⚠ The unique index usesCOALESCEoverfacility_id, because PostgreSQL treats NULLs as distinct in a unique index — without it, "declared once for the tenant" silently becomes "declared twice".- ⚠⚠ There is no auto-enrolment and no
auto_enrollcolumn (PO 2026-08-23). Enrolling somebody in a programme is an act a person performs, and for a confidential programme it is a disclosure. The mapping attributes an encounter to an enrolment that already exists; it never creates one. encounter_type_idis a logical reference with no foreign key, likeworkflow_visit_type_mapping.visit_type_id: a database FK across an engine boundary is the coupling the architecture forbids. Validated in-process through the clinical engine'srequireAssignableEncounterTypeinstead — which is the only thing standing between a typo and a mapping that silently matches no encounter ever recorded.- ⚠ The mapping table ships empty, correctly. No starter programmes ship (
programis empty after migration), so there is nothing to map. Seeding starter programmes is a product decision and its own ticket — seeCONFIG_SEED_INVENTORY.md.
API
See the API Reference. Endpoint groups under /api/v1/program: programs, workflows
and states, patient enrollment (enroll / complete-with-outcome / get / list-by-patient), and patient
state transitions, plus an access-filtered enrollment search.
M36-001 adds POST|GET /encounter-type-mappings, DELETE /encounter-type-mappings/{id} and
GET /programs/{programId}/encounter-types, gated on program.encounter-type-mapping.read / .write.
⚠ Deliberately not the enrolment permissions: declaring that ANC visits belong to the ANC
programme reveals nothing about any patient, while reading who is enrolled does.
Configuration health (M36-003)
GET /api/v1/program/configuration-health answers one question: what about this facility's programme
configuration does nothing?
It exists because M36-002 is deliberately quiet. Attribution stamps an encounter when an administrator has mapped its type to a programme, and when nobody has it stamps nothing and logs nothing — correct per encounter, because most encounters are not programme activity, and useless per facility, because a programme reporting zero looks identical whether nobody was treated or nobody finished the setup.
This is the M28-010 lesson applied to programmes: there, workflow_visit_type_mapping was empty and the
code that found it empty said nothing, so an unconfigured journey looked exactly like an untriggered one.
| Gap | What it means |
|---|---|
NO_MAPPING | An encounter type produced encounters here but counts toward no programme |
AMBIGUOUS_MAPPING | Two or more programmes claim one encounter type, so attribution cannot be automatic |
PROGRAM_INACTIVE | A mapping points at an inactive or deleted programme — configured-looking, attributing nothing |
PROGRAM_UNMAPPED | A programme has no encounter type mapped to it, so its volume is structurally zero |
UNATTRIBUTED_ENCOUNTERS | A rolling count of mapped-type encounters saved with no open enrolment |
⚠ The sweep starts from what actually happened, not from the mapping rows. Walking the mappings could only find faults in rows that exist, and the state worth reporting is the one with no rows.
⚠ An unmapped type nobody uses is not a gap. Every facility has dozens; listing them would bury the
one that produced four hundred encounters last month. NO_MAPPING is driven by encounter volume over a
90-day window, and carries that volume so the list can be read in priority order.
⚠ Read on demand, never pushed. A gap is a standing property of the configuration: one unmapped type
is one row, not one notification per patient who arrives with it. UNATTRIBUTED_ENCOUNTERS is likewise a
single count for the facility, not a row per encounter.
⚠ Facility-scoped from the session, never from a parameter, so one administrator cannot survey another facility's setup by changing an id in the URL. And the payload carries no patient identifier — a contracts test asserts the DTO has nowhere to put one.
Enrolling from a clinical form (M36-005)
A clinician enrols a patient by filling a Programme enrolment block on the form they are already using, and ends an enrolment with a Programme exit block. There is no auto-enrolment (PO 2026-08-23): enrolling somebody in a programme is an act a person performs, and for a confidential programme it is a disclosure, so it is never a side effect of recording care.
| Block | What it captures | When it is refused |
|---|---|---|
| Programme enrolment | Programme · start date · register number (per the programme's M36-004 strategy) | The patient already has an open enrolment in that programme |
| Programme exit | Programme · stop date · outcome concept | The patient has no open enrolment in that programme |
Both refusals are enforced server-side, in PatientProgramService, and name the programme. The
notice the form shows while you are filling it is a courtesy on top: the form path is not the only
caller of the programme API, so a rule enforced only in the renderer is a rule the API does not have.
Submitting the same form twice does not enrol twice. A network retry re-sends the same submission, which is one act arriving twice; it returns the enrolment it already created rather than being refused as a duplicate. Correcting a mistake on a submitted form (an amendment) does not re-run either block.
The encounter counts toward the enrolment the same form made. The enrolment is recorded before the encounter is created, so the visit on which a patient was enrolled is itself attributed to that enrolment rather than appearing as an unattributed encounter in the M36-003 health panel.
Which programme an encounter counts toward (M36-006)
A clinician recording care sees what the platform will do about programme attribution, and is asked to choose only when there is genuinely a choice. The screen shows one of four things, and they are deliberately not collapsed into "programme / no programme":
| What the platform found | What you see |
|---|---|
| This encounter type belongs to no programme | Nothing. Most encounter types are not programme activity, and a badge on every form would be noise |
| One of your patient's enrolments applies | A badge naming the programme and its register number. Nothing to decide |
| Several could apply | A picker. The platform will not guess, and an unanswered choice leaves the encounter unattributed |
| The type belongs to a programme, but this patient is enrolled in none of them | A prompt to enrol, naming the programme |
⚠ That last row is the one that matters. It means care is being recorded that a programme was configured to count, for a patient nobody enrolled — a gap somebody can close. Showing it the same way as "this is not programme activity" would hide it, which is why the two are separate states rather than an empty list.
Where the question is asked. On the form the clinician is already filling. There is no separate encounter screen to answer it on: clinical capture is drag-and-drop forms, and a form declares the encounter type it records, so the platform knows what to ask before a single field is filled. The control sits above the form, beside the patient summary, and appears only for forms that document a patient.
What happens to the answer. It rides with the submission and is stored on it, so a submission that is saved as a draft, retried or reprocessed is attributed the same way every time. ⚠ It is a choice, not a grant: the server revalidates it against that patient's own open enrolments and the encounter-type mapping, so an id that is not theirs is refused rather than honoured.
⚠ An enrolment made by the form itself outranks the picker. One form can enrol a patient and record their first visit. The encounter then belongs to the enrolment that form just created, not to whichever other programme was picked beforehand, so an enrolment's own first encounter always appears in its register.
The programme detail screen lists the encounters that counted.
GET /clinical/encounters?patientProgramId=… filters on the attribution M36-002 records, so the
enrolment page can answer "what happened under this enrolment" without a second source of truth.
Configuration & feature flags
None.
Related features
- Demographic (patients), Concept (outcomes),
Workflow (a
ProgramStateChangeHookseam lets the workflow engine react to state changes), Access control (enrollment filtering). Domain plug-ins read/write viaexchange.client.program.