Skip to main content

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

TablePurpose
programA program definition; data_sensitivity; concept-backed outcomes.
program_workflowA workflow within a program.
program_workflow_stateStates of a workflow (initial/terminal).
patient_programA patient's enrollment (unique open enrollment per patient+program).
patient_stateThe patient's state history (one open state per workflow).
program_encounter_type_mappingM36-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's DataSensitivity.
  • 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_id is 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 uses COALESCE over facility_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_enroll column (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_id is a logical reference with no foreign key, like workflow_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's requireAssignableEncounterType instead — 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 (program is empty after migration), so there is nothing to map. Seeding starter programmes is a product decision and its own ticket — see CONFIG_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.

GapWhat it means
NO_MAPPINGAn encounter type produced encounters here but counts toward no programme
AMBIGUOUS_MAPPINGTwo or more programmes claim one encounter type, so attribution cannot be automatic
PROGRAM_INACTIVEA mapping points at an inactive or deleted programme — configured-looking, attributing nothing
PROGRAM_UNMAPPEDA programme has no encounter type mapped to it, so its volume is structurally zero
UNATTRIBUTED_ENCOUNTERSA 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.

BlockWhat it capturesWhen it is refused
Programme enrolmentProgramme · start date · register number (per the programme's M36-004 strategy)The patient already has an open enrolment in that programme
Programme exitProgramme · stop date · outcome conceptThe 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 foundWhat you see
This encounter type belongs to no programmeNothing. Most encounter types are not programme activity, and a badge on every form would be noise
One of your patient's enrolments appliesA badge naming the programme and its register number. Nothing to decide
Several could applyA 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 themA 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.

  • Demographic (patients), Concept (outcomes), Workflow (a ProgramStateChangeHook seam lets the workflow engine react to state changes), Access control (enrollment filtering). Domain plug-ins read/write via exchange.client.program.