Skip to main content

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

AreaTables
Definition & versionsworkflow_definition (editable draft, unique code), workflow_version (append-only immutable snapshot)
Structureworkflow_stage, workflow_transition, workflow_role_assignment, workflow_rule, workflow_stage_receiver, workflow_stage_receiver_member
Runtimeworkflow_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; publishVersion snapshots 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_transition exists current→target; reaching a terminal stage auto-completes the instance; task lifecycle is PENDING → ASSIGNED → COMPLETED; every change appends an immutable workflow_event (never updated/voided).

  • A stage's roles are real roles (TERM-007). workflow_role_assignment.role_id is a foreign key to role.role_id, and assignment_type is the WorkflowRoleAssignmentType enum — 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 a 400; assigning the same role to a stage twice in the same capacity is a 409, while the same role in a different capacity is a distinct assignment. Responses carry the resolved roleCode and roleName alongside roleId, resolved one page at a time rather than per row.

    Until migration workflow/004 this was a free-text role_name joined to nothing, so Radiologist and RADIOLOGIST were 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_type carried the same defect more quietly: it was nullable, so the guard's assignment_type = NULL comparison 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.

EndpointPermission
POST /workflow/approval-delegationsworkflow.approval-delegation.write
GET /workflow/approval-delegations?delegatorPersonId=workflow.approval-delegation.read
POST /workflow/approval-delegations/{id}/voidworkflow.approval-delegation.write
POST /workflow/approval-delegations/void-by-sourceworkflow.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.

EndpointPermission
POST /workflow-instances/{id}/tasks/{taskId}/approval/decideworkflow.approval.write
GET /workflow-instances/{id}/approval/actionsworkflow.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.

EndpointPermission
POST /form/form-submissions/{id}/resubmitform.submission.write
GET /form/routing/consistency-gapsform.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:

EventRecipientsTemplate code
Stage task createddesignated assignee, or every ANY-mode candidate (delegates included)form_approval_pending
Rejectioninitiatorform_approval_rejected
Terminal approvalinitiatorform_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.

  • Queue (often paired for operational flow), Program (ProgramStateChangeHook). Domain plug-ins call via exchange.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.createVisit is the single choke point — token issuance (visit.visit_token, unique per facility/day, advisory-locked) + journey start via workflow_visit_type_mapping (visit type → published definition; also carries the M28-006 checkout_queue_id). SubjectType.VISIT; never-throw VisitJourneyService.
  • Parallel stages (M28-005): workflow_instance_stage open rows are the occupancy (current_stage_id dropped at the gate, workflow/014); parallel_split fans, join_type ALL|ANY reconverges, moves are branch-scoped (fromStageId required once forked), terminals end branches, override collapses all. DTO: activeStageIds authoritative, currentStageId derived (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-share behind workflow.tenant-definition.write; GLOBAL starters (workflow/013) are copy-only — mapping and startInstance refuse them; POST /{id}/copies deep-copies scope-aware (queue bindings only same-facility, role assignments only same-tenant, rule toStageId remapped).

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.

KindMeaningRemedy
NO_JOURNEYno mapping row, so check-in starts nothingmap the visit type to a published journey
NO_CHECKOUT_QUEUEmapping exists but names no queue, so discharge routes nowheregive the mapping a checkout queue
DEFINITION_UNPUBLISHEDthe mapped definition is no longer publishedpublish 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:

ValueMigrations
LEAVE_REQUESTqueue/004 + workflow/007
CRITICAL_RESULTqueue/005 + workflow/008
VISITqueue/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 itQueueServiceImpl.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

  1. Copy a starterPlatform startersCopy 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.
  2. 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.
  3. PublishDrafts awaiting publicationPublish. A draft cannot be mapped.
  4. 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

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

EndpointPermission
GET /api/v1/workflow/workflow-categoriesworkflow.category.read
POST /api/v1/workflow/workflow-categoriesworkflow.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-typesworkflow.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 errorVisitJourneyServiceImpl. 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 oneWorkflowConfigurationHealthServiceImpl. 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 copyWorkflowDefinitionCopyServiceImpl. 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 typeWorkflowSubjectTypeController. 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.