Workflow — How it works
Overview
The workflow engine is a generic, versioned process engine: define a workflow, edit its structure as a draft, publish immutable versions, then run instances that move through declared stages, raise tasks, and record an append-only event log. It carries no clinical/domain knowledge — domain plug-ins (and the program engine's state-change hook) drive it.
Data model & ownership
| Area | Tables |
|---|---|
| Definition & versions | workflow_definition (editable draft, unique code), workflow_version (append-only immutable snapshot) |
| Structure | workflow_stage, workflow_transition, workflow_role_assignment, workflow_rule, workflow_stage_receiver, workflow_stage_receiver_member |
| Runtime | workflow_instance, workflow_task, workflow_task_candidate, workflow_approval_action (append-only), workflow_event (append-only) |
Key rules & invariants
-
Published versions are immutable. Structure is edited only while the definition is a draft;
publishVersionsnapshots the config and bumps the version. -
Transition-graph validation: no self-transition, both stages must belong to the same definition, no duplicate transitions/codes, and at most one initial stage.
-
Runtime: instances run only published definitions and start at the declared initial stage; a move is rejected unless a declared
workflow_transitionexists current→target; reaching a terminal stage auto-completes the instance; task lifecycle isPENDING → ASSIGNED → COMPLETED; every change appends an immutableworkflow_event(never updated/voided). -
A stage's roles are real roles (TERM-007).
workflow_role_assignment.role_idis a foreign key torole.role_id, andassignment_typeis theWorkflowRoleAssignmentTypeenum —ASSIGNEE(may do the work),REVIEWER(may verify it),APPROVER(may sign it off),WATCHER(notified only). Both are required. Assigning a role that does not exist is a400; assigning the same role to a stage twice in the same capacity is a409, while the same role in a different capacity is a distinct assignment. Responses carry the resolvedroleCodeandroleNamealongsideroleId, resolved one page at a time rather than per row.Until migration
workflow/004this was a free-textrole_namejoined to nothing, soRadiologistandRADIOLOGISTwere two cohorts on one stage and the duplicate guard — case-sensitive string equality — reported nothing. Whichever spelling the authorisation check used, the other cohort was silently locked out or silently permitted.assignment_typecarried the same defect more quietly: it was nullable, so the guard'sassignment_type = NULLcomparison was never true and the partial unique index treated NULLs as distinct, and untyped duplicates were accepted twice over.
API
See the API Reference. Endpoint groups under /api/v1/workflow: workflow
definitions (+ versions), draft structure (/{id}/stages|transitions|rules, /stages/{id}/role-assignments,
/stages/{id}/receivers for form-approval routing),
and runtime instances (start/get/search/move/cancel/events/tasks) + /workflow-tasks/{id}/assign|complete.
M34-004 — form-approval stage receivers (2026-08-26)
Form-approval workflows (workflow_category code FORM_APPROVALS) route items to approvers via
workflow_stage_receiver, distinct from workflow_role_assignment which governs who may act.
Receiver kinds are enums: ROLE, DEPARTMENT, DEPARTMENT_MEMBERS; selection mode is DESIGNATED
or ANY. ANY-mode tasks materialize candidates in workflow_task_candidate at task creation.
Draft-only CRUD: POST/GET/DELETE /workflow-definitions/{id}/stages/{stageId}/receivers plus member
rows for DEPARTMENT_MEMBERS. Publish gate for FORM_APPROVALS: linear structure (no
parallel_split), ≥1 receiver on every non-terminal stage, and auto-declared REJECT back-transitions
to the initial stage. StageReceiverResolutionService unions configured receivers, excludes the
initiator (self-approval guard), and applies a one-hop delegation substitution via
workflow_approval_delegation (M34-007).
M34-007 — approval delegations (2026-08-27)
workflow_approval_delegation stores who may sign on whose behalf for a date range. Sources are
LEAVE (written by workforce when a leave request is approved) or MANUAL (out-of-office screen).
One hop only — latest active row wins; delegation to the initiator is dropped at resolution time.
| Endpoint | Permission |
|---|---|
POST /workflow/approval-delegations | workflow.approval-delegation.write |
GET /workflow/approval-delegations?delegatorPersonId= | workflow.approval-delegation.read |
POST /workflow/approval-delegations/{id}/void | workflow.approval-delegation.write |
POST /workflow/approval-delegations/void-by-source | workflow.approval-delegation.write |
Workforce: leave_request.reliever_staff_profile_id + LeaveRequestFormSubmissionHandler
(leave_request) maps the FORM-109 pilot form; on APPROVED a LEAVE delegation is recorded over
ApprovalDelegationClient (non-blocking — failure logs, does not roll back the leave decision).
M34-005 — approval routing service (2026-08-26)
The act of signing is recorded in append-only workflow_approval_action (one row per decided task,
unique on workflow_task_id). ApprovalRoutingService composes the existing instance engine —
moveStage on declared transitions, task complete/assign — rather than a second state machine.
| Endpoint | Permission |
|---|---|
POST /workflow-instances/{id}/tasks/{taskId}/approval/decide | workflow.approval.write |
GET /workflow-instances/{id}/approval/actions | workflow.approval.read |
Approve (one transaction): complete task → append APPROVED action → move forward → next stage
creates an APPROVAL task (ASSIGNED when the previous actor picked the receiver on a DESIGNATED
stage; PENDING + materialized candidates for ANY). Reject requires a comment, moves back
via the auto-declared REJECT transition, assigns a REVISE task to the initiator, and projects
REJECTED through FormRoutingStatusProjection.
M34-006 — form → workflow binding (2026-08-27)
form_definition.approval_workflow_definition_id binds a published FORM_APPROVALS workflow whose
subject type is FORM_SUBMISSION. Form publication validates the binding (published, correct
category, correct subject). On accept, FormSubmissionRoutingStarter starts an instance in the
same transaction, sets workflow_instance_id, projects routing_status = IN_PROGRESS, and calls
ApprovalRoutingService.startRouting for the stage-1 task. Unbound forms and forms whose workflow
was later voided/unpublished still accept submissions with routing_status null.
| Endpoint | Permission |
|---|---|
POST /form/form-submissions/{id}/resubmit | form.submission.write |
GET /form/routing/consistency-gaps | form.submission.read |
Resubmit voids the rejected row, recreates with replaces_submission_id, repoints the instance
subject, and appends RESUBMITTED. GET /form/routing/consistency-gaps reports projection drift
(M28-010 style): status/instance mismatch, routed-without-open-task, single-state violations.
Hard rules enforced in the service layer: initiator cannot approve their own submission (403); only
assignees/candidates may decide (403); actions are never updated or deleted; assignee_id is written
as canonical lowercase UUID strings on every routing write path.
M34-009 — approval notifications (2026-08-27)
ApprovalRoutingNotifier enqueues IN_APP events through NotificationEventService.enqueueEvent(dto, tenantId, facilityId) when:
| Event | Recipients | Template code |
|---|---|---|
| Stage task created | designated assignee, or every ANY-mode candidate (delegates included) | form_approval_pending |
| Rejection | initiator | form_approval_rejected |
| Terminal approval | initiator | form_approval_completed |
Template variables are display-safe only: formName, submissionRef, stage, actorDisplay, and
comment on rejection — never raw form payload. Seeded on the Global tenant by
notification/008-form-approval-templates.yaml. Notification assembly and provider failures are
best-effort: logged and swallowed, never rolling back the routing transaction.
M34-010 — leave-request approval starter (2026-08-28)
Platform starter UHP_STARTER_LEAVE_APPROVAL (workflow/020): DEPT_HEAD (ROLE UHP_PILOT_DEPT_HEAD,
DESIGNATED) → HR (ROLE UHP_PILOT_HR, ANY) → COMPLETE. Adopt via
POST /api/v1/workflow/workflow-definitions/{id}/copies, assign staff to pilot roles, publish the copy.
Bound to global leave-request form (form/036, FORM-109). Gate:
Milestone34FormApprovalsIntegrationTest; record: core/MILESTONE34.md.
Configuration & feature flags
Domain-module Feign URL workflow.service.url (WORKFLOW_SERVICE_URL) — used only by plug-ins.
Related features
- Queue (often paired for operational flow), Program
(
ProgramStateChangeHook). Domain plug-ins call viaexchange.client.workflow.
Milestone 28 — orchestration (2026-08-12)
M28 turned the M8 engine into the patient-journey orchestrator. Full design record:
MILESTONE28_PLAN.md (per-ticket "as built" blocks); user walkthroughs:
../guides/clinical-workflow-orchestration.md; summary: core/MILESTONE28.md.
- Override (
POST /workflow-instances/{id}/override): any stage of the definition, mandatory reason, own permission (workflow.instance-override.write), own event type (STAGE_OVERRIDDEN) so bypasses stay countable. - Rules evaluated (
WorkflowRuleEvaluator):REQUIRE_DATA/CONDITIONAL_BRANCH/AUTO_ADVANCE(depth-capped) /TRIGGER_ON_EVENT(queue facts advance the journey via the after-commit listener). - Queue coupling:
workflow_stage.queue_id; entry enqueues, leaving ends the item, override moves both; integration derived on read. - Check-in (M28-004):
VisitService.createVisitis the single choke point — token issuance (visit.visit_token, unique per facility/day, advisory-locked) + journey start viaworkflow_visit_type_mapping(visit type → published definition; also carries the M28-006checkout_queue_id).SubjectType.VISIT; never-throwVisitJourneyService. - Parallel stages (M28-005):
workflow_instance_stageopen rows are the occupancy (current_stage_iddropped at the gate, workflow/014);parallel_splitfans,join_type ALL|ANYreconverges, moves are branch-scoped (fromStageIdrequired once forked), terminals end branches, override collapses all. DTO:activeStageIdsauthoritative,currentStageIdderived (single stage, null while forked, final stage once ended). - Shared templates (M28-007): derived
scope=PLATFORM|TENANT|FACILITY; facility definitions are invisible to siblings;POST /{id}/tenant-sharebehindworkflow.tenant-definition.write; GLOBAL starters (workflow/013) are copy-only — mapping andstartInstancerefuse them;POST /{id}/copiesdeep-copies scope-aware (queue bindings only same-facility, role assignments only same-tenant, ruletoStageIdremapped).
M11-008 — the stage ↔ form binding (2026-08-12)
workflow_stage_form binds a published form to a stage (draft-only CRUD under
/workflow-definitions/{id}/stages/{stageId}/forms; min/max submissions, partial unique per
stage+form). form_submission carries workflow_instance_id/workflow_stage_id (form/034), and
the stage-exit gate counts non-rejected, non-failed submissions through the form engine's service:
moveStage refuses naming the form; auto-advance parks (AUTO_ADVANCE_HALTED); a queue trigger
holds as a logged no-op; the override bypasses the gate (M28-001 posture).
M28-010 — an unconfigured journey is now visible (2026-08-16)
M28-004 wired the check-in journey correctly and it had never started one in production. The wiring
is right: VisitServiceImpl calls the journey seam on every check-in path — appointment arrival,
walk-in, admission. What was missing is any row to find. workflow_visit_type_mapping was empty, and
it has no seed data: every deployment starts with none.
Why nobody noticed
VisitJourneyServiceImpl returned Optional.empty() with no log line when there was no mapping.
The error branch logged; the missing-configuration branch did not — and missing configuration is
the state every deployment starts in. So a visit type that started no journey was indistinguishable at
runtime from one that worked: the check-in succeeded, nothing started, nothing recorded that a
configured behaviour had not happened.
Both empty branches now log at warn, naming the visit type and facility only. Not error,
because an unconfigured visit type is a setup gap rather than a fault, and an error per check-in would
bury the log that matters. And never a patient or visit id: the line is read from aggregated logs, and
the gap belongs to the configuration, not to whoever happened to walk in.
GET /api/v1/workflow/configuration-health
Lists what about this facility's setup does nothing. Requires
workflow.configuration-health.read. Facility comes from the session, never a parameter, so one
facility's administrator cannot survey another's.
| Kind | Meaning | Remedy |
|---|---|---|
NO_JOURNEY | no mapping row, so check-in starts nothing | map the visit type to a published journey |
NO_CHECKOUT_QUEUE | mapping exists but names no queue, so discharge routes nowhere | give the mapping a checkout queue |
DEFINITION_UNPUBLISHED | the mapped definition is no longer published | publish it again, or map to one that is |
⚠ It walks the visit-type catalog, not the mappings. Walking the mappings could only find faults in rows that exist — and the state this was raised for is zero rows, which would have reported a clean bill of health on a facility that starts no journey at all.
⚠ DEFINITION_UNPUBLISHED is worth its own kind because it worked once. createMapping refuses an
unpublished definition, so it can only arise by unpublishing afterwards, and from the desk it looks
exactly like having no mapping — reporting it as NO_JOURNEY would send an administrator to create a
mapping that already exists.
Read on demand, not pushed. A gap is a standing property of the configuration: one unmapped visit type is one row here, not one notification per patient who arrives with it.
The never-throw posture is unchanged (M28-004): a configuration defect must not refuse the patient standing at the desk. This makes the gap visible, not fatal.
WORKFLOW-SUBJECT-001 — subject types are a registry, not an enum (2026-08-16)
What a workflow instance or queue item is about used to be a closed enum (SubjectType) backed by
CHECK constraints on queue_item.subject_type and workflow_instance.subject_type.
That cost was measurable, not theoretical. Three values were added after TERM-008O and each needed two forward migrations, because two tables carried the constraint:
| Value | Migrations |
|---|---|
LEAVE_REQUEST | queue/004 + workflow/007 |
CRITICAL_RESULT | queue/005 + workflow/008 |
VISIT | queue/006 + workflow/010 |
Six migrations for three values, all in core — and a decoupled domain module can write none of them.
So Procurement could not route a requisition through workflow and Finance could not route an expense
claim without a core release. A CHECK constraint that no module can widen is not a safety
property; it is a release dependency.
What replaced it
workflow_subject_type — a registry row per subject type, seeded with the original five owned by the
GLOBAL tenant so every deployment keeps working unchanged. A module registers its own:
POST /api/v1/workflow/subject-types { "code": "PURCHASE_REQUISITION", ... }
GET /api/v1/workflow/subject-types
behind workflow.subject-type.write / .read, or over Feign via WorkflowSubjectTypeClient.
⚠ Registration is idempotent on code, so a module's startup registration is safe to repeat and
two modules racing cannot create duplicates.
⚠ Validation moved from the database to the service
The two CHECK constraints are dropped. That is a deliberate loosening: the database cannot see a
table a domain module writes at runtime. WorkflowSubjectTypeService#requireUsable is the
replacement, and every write path that stores a subject type calls it —
QueueServiceImpl.addItem, addExplicitScopeItem and WorkflowInstanceServiceImpl.startInstance.
If you add a new write path that persists a subject_type, call it. Nothing downstream will catch
an invented value any more.
SubjectType is now constants, not an enum
The five platform values remain as String constants so platform code names them safely — a typo in a
string literal is a queue nobody is watching. SubjectType.PLATFORM_TYPES lists them.
⚠ PLATFORM_TYPES is not the set of valid types. A tenant's registered type is equally valid and
will never appear there. Validate through requireUsable, never against that list.
⚠ Do not add a constant to introduce a new subject type — that reintroduces the release dependency this removed. A constant belongs there only when platform code must name the value itself.
There is no delete
Instances and queue items store the code, with no foreign key to the registry — it replaced a constraint, not a relationship. Deleting a row would leave existing work naming something that no longer exists, so the API offers deactivation instead: new work cannot reference it, old work stays readable.
Setting up a check-in journey — the whole chain (2026-08-18)
Verified end to end against a live backend and the admin UI. Workflows → Check-in journeys.
The chain is copy → bind queues → publish → map
Platform starter ──copy──▶ facility draft ──bind queues──▶ publish ──▶ map to a visit type
▲
do this HERE, or never
⚠ A workflow must declare what it is about before step 4 will accept it (M28-013). Every
definition now carries a subject type — a row in the subject-type registry, not a free-text
label — and mapping a visit type to a definition whose subject type is not VISIT is refused with
the mismatch named:
Workflow definition LEAVE_APPROVAL is about LEAVE_REQUEST, not VISIT, so a visit type cannot start it.
The three platform starters ship as VISIT, and a copy inherits its source's subject type, so
the copy → publish → map path needs no extra step. A definition created from scratch does: the
New workflow definition form requires it, and the list's About column shows — for anything
unclassified. — is not cosmetic — such a definition can never be mapped.
Before M28-013 a definition had no subject type at all. startJourneyForVisit set VISIT on the
instance from the start request, so a workflow written for something else could be mapped to a
visit type and would run anyway. "Workflows of type Visit" was a label; now it is a relationship.
⚠ The bind step is the one that gets skipped, and it cannot be done later. Stage edits are
draft-only (assertDraft): once a definition is published it is immutable, so a stage can no longer
be given a queue. The shipped starters arrive with no queue bound to any stage, so a copy you
publish untouched starts journeys that run correctly and place nobody on a waiting board. The
only remedy afterwards is to copy the starter again.
Steps
- Copy a starter — Platform starters → Copy to this facility. This creates a draft the facility owns. GLOBAL starters are refused by the server ("copy it into this facility first") and are shown greyed out in the picker for that reason.
- Bind queues to the stages — in the workflow builder, while it is still a draft. A stage with no queue is a stage no one sees.
- Publish — Drafts awaiting publication → Publish. A draft cannot be mapped.
- Map the visit type — pick the visit type, the published workflow, and a checkout queue, then Map visit type.
If a published, facility-owned workflow already exists, only step 4 is needed.
What each half does at runtime
| Action | What happens |
|---|---|
| Start visit (patient dashboard) | Visit opens, daily token is issued, and the mapped journey starts — workflow_instance with subject_type = VISIT. If the initial stage binds a queue, the patient is enqueued there. |
| End visit (patient dashboard) | The visit is routed into the mapping's checkout queue as a WAITING item. |
⚠ Checkout does not complete the journey, deliberately. Routing hangs off the clinical visit,
not workflow completion (M28-006), so a clinic with no pathway at all still checks patients out into
a billing queue. A journey instance therefore stays ACTIVE after checkout — that is correct, not a
leak.
⚠ Nothing here refuses the patient. An unmapped visit type still opens the visit and still issues a token; it simply runs no pathway. That is why the gap was invisible before M28-010 — see the configuration-health panel above.
Confirming it works
The Check-in journeys panel shows "Every visit type here starts a published journey and routes somewhere at checkout" when nothing is missing, and lists each gap with its remedy when something is. It refreshes as soon as a mapping is added or removed.
Workflow categories (M28-013)
workflow_definition.category used to be a free-text VARCHAR(80). The live data held both failure
modes of an unconstrained categorical at once: 'CLINICAL' on the three platform starters and
null on the only tenant-owned definition. Governing modelling rule 1 — a categorical is an enum or
a user-managed CRUD entity with a management screen — makes it the latter, because a tenant will
want its own categories.
Workflows → Workflow categories. The platform ships one row, CLINICAL, owned by the GLOBAL
tenant and marked system_defined; it cannot be retired, because the starters reference it. A tenant
adds its own alongside it, and may reuse a platform code to override it.
Ownership follows the three TypeScope levels (facility, tenant, the tenant's country) plus a
fourth arm for the platform's own rows. TypeScope deliberately has no world-wide level — a national
identifier scheme must not leak between countries — but a workflow category is platform
configuration rather than national reference data, and CLINICAL has to exist on a fresh install
anywhere. The two concerns are kept as separate arms rather than by widening TypeScope.
The old category column is still written and still returned. It is deprecated, not dropped: the
frontend reads it, and a forward migration must not break a running client.
| Endpoint | Permission |
|---|---|
GET /api/v1/workflow/workflow-categories | workflow.category.read |
POST /api/v1/workflow/workflow-categories | workflow.category.write |
PUT /api/v1/workflow/workflow-categories/{id} | workflow.category.write |
DELETE /api/v1/workflow/workflow-categories/{id} | workflow.category.write |
GET /api/v1/workflow/subject-types | workflow.subject-type.read |
GET /workflow-definitions also accepts ?subjectTypeId= and ?workflowCategoryId= — for example,
listing only the journeys a visit type could start.
Why it is this way
Versions are append-only snapshots, and publication is final. Structure is editable only while a version is a draft; publishing snapshots the configuration and bumps the version. Running instances refer to the version they started under, so an editable published version would retrospectively rewrite the pathway of care already given — the record would describe a journey nobody followed. Changing a journey therefore means publishing a new version, and in-flight instances stay where they are.
Subject type became data rather than an enum (M28-013). The engine runs patient visits, leave requests, shift swaps and critical-result review; a closed enum meant every new subject needed a code change and a migration. Making it a registry with a management screen is the platform's general rule: a categorical is either an enum or a user-managed entity with a UI, never a half-built third thing.
A missing configuration is reported, not inferred. WorkflowConfigurationHealthService exists
because M28-004 was wired correctly and had still never started a journey in production — the mapping
table was empty and the code that found it empty said nothing at all.
Traps
⚠ An unmapped visit type logs at warn, not error — VisitJourneyServiceImpl. A setup gap is not
a fault, and logging it as an error would train operators to ignore the error log. It is still a gap:
check-in opens the visit and issues the token, and simply starts no pathway.
⚠ That log line carries visit type and facility only — no patient id, no visit id, no names. It is read by people who are not entitled to clinical data.
⚠ The health check counts any stage without a queue, not just the initial one —
WorkflowConfigurationHealthServiceImpl. A journey whose first stage is queueless but whose later
stages are fine is still broken for the patient who reaches those later stages.
⚠ Classification travels with a copy — WorkflowDefinitionCopyServiceImpl. Copying a starter
without its subject type and category produces a definition that cannot be mapped to anything, which
surfaces only when someone tries to use it.
⚠ There is deliberately no delete for a subject type — WorkflowSubjectTypeController. A code
that has been used is referenced by existing instances; deactivation is the retirement path.
⚠ Instance override is a separate permission from instance write — WorkflowInstanceController.
Forcing an instance past a stage is a different act from advancing it normally.