A FHIR-native, event-sourced workflow engine for prior authorization — a reference implementation of how to orchestrate a regulated, long-running healthcare decision that pauses on external systems and humans and resumes deterministically.
It ships as three modules: a small, generic orchestration engine with zero domain or framework dependencies; a prior-auth domain built on top of it (FHIR-flavored model, pluggable capabilities, a mock payer); and a Spring Boot demo exposing the flow over HTTP plus an offline CLI that runs full scenarios.
Built by Nirmitee.io. Companion write-up: Prior authorization is a workflow problem (CMS-0057).
A bundled operator console (web/) drives and visualizes the engine —
start a prior-auth, watch the parallel fork–join intake, release the async holds,
act on the review queue, and read the event-sourced log:
Most prior-auth software models the request as a form: fields in, a decision out. But a real prior authorization is a long-running process that:
- spans multiple systems (eligibility, coverage rules, documentation, the payer);
- waits — on an X12 278 round-trip that may take minutes to days, or on a human utilization-management reviewer;
- branches — approve, deny, pend, request more documentation, appeal;
- must be auditable — every state change explained and replayable.
Modeling that as a form leads to brittle status columns and lost context. Modeling it as a workflow with an event-sourced log makes the pauses, branches, and the full history first-class.
Two ideas do the heavy lifting.
1. The orchestrator owns sequence; capabilities own the domain. The engine knows when each step runs and how outcomes route between states. Each capability (eligibility, CRD, DTR, medical necessity, PAS submission, human review) knows only what it does and which outcome it returns. You can add, reorder, or swap capabilities without touching the engine.
2. The event log is the single source of truth. Nothing stores "current state" in a mutable field. An instance's state, whether it is held, and its accumulated data are all derived by folding its append-only event log. That is what makes long-running workflows deterministic and crash-tolerant: to know where an instance is, replay its events.
Supporting mechanics:
- Asynchronous holds. A step that must wait returns
Hold; the instance parks until it issignalled with a result. The same step is then re-executed and now sees the result — the re-entrant step contract. - Human-in-the-loop. A hold can also open a
HumanTaskon a queue; resolving the task signals the workflow with the reviewer's decision. - Straight-through vs pended. An
AUTOprocedure sails through to approval with no human; aPENDprocedure routes to a clinical-review queue. Same graph, data decides the path.
The flowchart below is generated from the process definition — run ./gradlew :demo:bootRun --args='mermaid' to regenerate it, so it can never drift from the code. The operator UI renders the same diagram per instance with the live status of each state overlaid.
flowchart TD
RECEIVED["RECEIVED"]
ELIGIBILITY["ELIGIBILITY"]
COVERAGE_REQUIREMENTS["COVERAGE_REQUIREMENTS"]
DOCUMENTATION_PREFETCH["DOCUMENTATION_PREFETCH"]
INTAKE{{"INTAKE"}}
REQUIREMENTS_EVALUATED{"REQUIREMENTS_EVALUATED"}
GATHERING_DOCUMENTATION["GATHERING_DOCUMENTATION<br/>(checkpoint)"]
MEDICAL_NECESSITY["MEDICAL_NECESSITY"]
SUBMITTED{"SUBMITTED"}
IN_CLINICAL_REVIEW{"IN_CLINICAL_REVIEW"}
APPROVED(["APPROVED"])
DENIED(["DENIED"])
RECEIVED --> INTAKE
ELIGIBILITY --> REQUIREMENTS_EVALUATED
COVERAGE_REQUIREMENTS --> REQUIREMENTS_EVALUATED
DOCUMENTATION_PREFETCH --> REQUIREMENTS_EVALUATED
INTAKE --> ELIGIBILITY
INTAKE --> COVERAGE_REQUIREMENTS
INTAKE --> DOCUMENTATION_PREFETCH
REQUIREMENTS_EVALUATED -->|not-required| APPROVED
REQUIREMENTS_EVALUATED -->|required| GATHERING_DOCUMENTATION
GATHERING_DOCUMENTATION --> MEDICAL_NECESSITY
MEDICAL_NECESSITY --> SUBMITTED
SUBMITTED -->|approved| APPROVED
SUBMITTED -->|denied| DENIED
SUBMITTED -->|pended| IN_CLINICAL_REVIEW
IN_CLINICAL_REVIEW -->|approved| APPROVED
IN_CLINICAL_REVIEW -->|denied| DENIED
IN_CLINICAL_REVIEW -->|rework| GATHERING_DOCUMENTATION
GATHERING_DOCUMENTATION -.->|jump| ELIGIBILITY
Diagrams (in docs/images/):
prior-authorization-workflow-lifecycle.png— the end-to-end lifecycleprior-authorization-orchestrator-capabilities.png— orchestrator vs capabilitiesprior-authorization-request-state-machine.png— the request state machine
The engine is a small but real workflow engine. A process is authored with a fluent DSL and compiled to the event-sourced runtime:
workflow("prior-authorization") {
val received = task("RECEIVED", ReceivedStep())
val eligibility = task("ELIGIBILITY", EligibilityStep(payer)) // async: holds on 270/271
val coverage = task("COVERAGE_REQUIREMENTS", CoverageRequirementsStep())
val prefetch = task("DOCUMENTATION_PREFETCH", DocumentationPrefetchStep())
val intake = parallel("INTAKE") { branch(eligibility); branch(coverage); branch(prefetch) }
val requirements = decision("REQUIREMENTS_EVALUATED", RequirementsEvaluatedStep())
// ... more nodes ...
start(received)
received.doNext(intake)
intake.join(requirements)
requirements.branch("not-required" to approved, "required" to gathering)
checkpoint(gathering)
allowJump(from = gathering, to = eligibility, clearKeys = setOf("eligibility.active"))
}- Fluent DSL —
task,decision(...).branch(...),parallel { branch(...) }.join(...),terminal,doNext,checkpoint,allowJump. - Parallel fork–join — an intake stage runs eligibility, CRD, and DTR-prefetch
concurrently and joins only when all three complete. Concurrency here is logical
and deterministic (branches can each be in-flight/held at once — e.g. three
outstanding payer calls — but the engine steps them single-threaded so replay is
exact), recorded in the log as
FORKED→BRANCH_HELD/BRANCH_COMPLETED→JOINED. - Editable jumps —
engine.jump(id, toState, reason)performs back-navigation, allowed only where the process registeredallowJump; illegal jumps are rejected. Used to correct demographics and re-run the eligibility inquiry. - Checkpoints — mark states as checkpoints and
engine.resetToCheckpoint(id)to rewind. - Step status —
instance.statuses()derives aStepStatus(PENDING/RUNNING/SUSPENDED/COMPLETED/SKIPPED/FAILED/TERMINATED) for every state from the log, so an audit shows what ran, what is parked, and what was skipped.
Prior auth is dominated by waiting — on payers and on people — so the engine is built to hold huge numbers of in-flight authorizations cheaply and to make the active work non-blocking (v0.3).
1. Durable holds → in-flight volume at ~zero thread cost.
A parked instance isn't a suspended thread or a blocked request — it is simply the
absence of a next event in its log. Ten thousand authorizations waiting on a payer
or a reviewer occupy ten thousand rows, not ten thousand threads. Resuming one is a
signal that appends RESUMED and drives. This is the property that lets a single
process carry an enormous backlog of waiting work.
2. Coroutines make the active path non-blocking.
Between holds, driving a step may do real I/O (an eligibility inquiry, a PAS
submission). Those are now suspend calls: Step.execute is suspend, the engine's
public API (start, signal, resolveHumanTask, jump, …) is suspend, and the
Spring MVC controller handlers are suspend fun — so an HTTP request coroutine
awaiting a payer never pins a servlet thread. We use idiomatic Kotlin coroutines,
not Project Reactor Mono: the engine stays framework-agnostic (no reactive
types leak into the domain), the code reads as straight-line sequential logic, and
the deterministic event-sourced fold is untouched. The drive loop is still
strictly sequential — it takes one step, folds, takes the next — so suspend
buys non-blocking I/O without introducing real concurrency into the fold, and
replay remains exact.
3. The pluggable, durable EventStore is the horizontal-scale lever.
EventStore (and HumanTaskStore) methods are suspend, so a durable or remote
store can offload its blocking work to a background dispatcher. The demo ships a
durable JDBC implementation, JdbcEventStore,
backed by embedded H2: the whole log is one append-only workflow_event table
keyed by (instance_id, seq), and its JDBC runs on Dispatchers.IO. Because state
is only the log, any engine instance pointed at the same store rebuilds identical
state — so the store is what turns "one process" into "many processes sharing a log"
(and what lets state survive a restart). Toggle it with a property:
# demo/src/main/resources/application.properties
priorauth.store=jdbc # default: memory (in-memory, lost on restart)memory uses the in-memory store; jdbc builds a file-backed H2 data source
(jdbc:h2:file:./data/priorauth;AUTO_SERVER=TRUE) and the durable store. A demo
test drives an instance to a hold, then folds it back with a brand-new engine over
the same store and gets byte-for-byte identical state — event sourcing across a
simulated restart. (The human-task store stays in-memory for now; a production build
would make it durable on the same seam.)
| Module | Package | Depends on | What it is |
|---|---|---|---|
engine |
io.nirmitee.workflow |
— | Generic, reusable orchestration engine. Pure Kotlin, zero Spring/domain deps. |
priorauth |
io.nirmitee.priorauth |
engine |
FHIR-flavored model, the prior-auth capabilities, the composed process, and a mock payer. |
demo |
io.nirmitee.priorauth.demo |
priorauth |
Spring Boot app (HTTP) + offline CLI scenario runner. |
The Gradle wrapper is committed; you only need JDK 21 on your JAVA_HOME.
./gradlew build # compile + run all tests (22 across engine/priorauth/demo)Runs three prior-auth cases end to end and prints each event log — no server, no network:
./gradlew :demo:bootRun --args='demo'Scenarios: (1) an AUTO procedure straight-through to APPROVED with no human;
(2) a PEND procedure held for review, then a reviewer approves it; (3) a
PEND procedure the reviewer sends back for rework, looping to documentation
and re-submission, ending DENIED; (4) the parallel fork–join intake shown
explicitly (three branches, one held on the 270/271, joining on release); (5) a
jump correction that rewinds to eligibility and re-runs it.
./gradlew :demo:bootRunThen drive the flow with curl:
# Start an AUTO request -> parks in the parallel intake, held on the 270/271
curl -s -X POST localhost:8080/api/prior-auth \
-H 'content-type: application/json' \
-d '{"patientName":"Jane Roe","patientBirthDate":"1985-04-12",
"payerId":"PAYER-1","memberId":"MEM-1",
"procedureCode":"AUTO-45378","procedureDisplay":"Screening colonoscopy",
"diagnosisCode":"M54.5"}'
# -> {"instanceId":"pa-xxxxxxxx","state":"INTAKE","held":true,"terminal":false}
# Feed the 270/271 eligibility response -> intake joins -> parks for documentation
curl -s -X POST localhost:8080/api/prior-auth/pa-xxxxxxxx/eligibility-response \
-H 'content-type: application/json' -d '{"active":true}'
# Complete the DTR documentation -> flows to SUBMITTED (held on the payer)
curl -s -X POST localhost:8080/api/prior-auth/pa-xxxxxxxx/documentation
# Simulate the async PAS/278 payer response -> APPROVED
curl -s -X POST localhost:8080/api/prior-auth/pa-xxxxxxxx/payer-callback
# Inspect the full folded instance + event history (FORKED / JOINED / HELD / ...)
curl -s localhost:8080/api/prior-auth/pa-xxxxxxxx
# A PENDED request pends at clinical review; list open tasks and decide
curl -s localhost:8080/api/tasks
curl -s -X POST localhost:8080/api/tasks/<taskId>/decision \
-H 'content-type: application/json' \
-d '{"decision":"APPROVED","by":"dr.house","comment":"criteria met"}'
# Editable back-navigation: correct demographics and re-run eligibility
curl -s -X POST localhost:8080/api/prior-auth/pa-xxxxxxxx/jump \
-H 'content-type: application/json' -d '{"toState":"ELIGIBILITY","reason":"corrected DOB"}'The CMS Interoperability and Prior Authorization rule (CMS-0057-F) pushes payers and providers toward API-driven prior auth built on the HL7 Da Vinci implementation guides:
- CRD (Coverage Requirements Discovery) — is prior auth required? →
CoverageRequirementsStep - DTR (Documentation Templates and Rules) — gather the required documentation →
DocumentationStep - PAS (Prior-Authorization Support) — submit and receive the determination, carrying X12 278 →
SubmissionStep
Eligibility maps to X12 270/271. The capability seams are exactly where a production system plugs these standards in; the orchestration around them is what this engine provides.
This is a reference implementation, deliberately simplified:
- the payer and clearinghouse are a deterministic in-memory mock;
- the event store defaults to in-memory (state is lost on restart); the demo
also ships a durable H2/JDBC store (
priorauth.store=jdbc), but H2 embedded is a demo database, not a production event log; - the human-task store is in-memory;
- the domain types are FHIR-flavored, not real FHIR resources.
A production build would use HAPI FHIR resources exchanged as a Da Vinci PAS
Bundle (Claim / ClaimResponse), a durable, append-only event store (the
JDBC store here is the shape of it, on a production-grade database), and real
payer / clearinghouse integration behind the same capability interfaces. The
engine's execution model is intended to carry over unchanged.
A small Next.js + TypeScript + Tailwind operator console lives in web/.
It consumes the same HTTP API and makes the engine's behavior visible: start a
prior-auth, watch the parallel fork–join intake, release the async holds
(270/271 eligibility, DTR documentation, PAS/278 payer callback), act on the human
review queue, run a jump correction, and read the event-sourced log.
Run it against a live backend:
# 1. Start the engine on :8080 (in one terminal)
./gradlew :demo:bootRun
# 2. Start the console on :3000 (in another terminal)
cd web
npm install
npm run devThen open http://localhost:3000. The backend's CORS config allows
http://localhost:3000. The API base URL defaults to http://localhost:8080 and
can be overridden with NEXT_PUBLIC_API_BASE.
The instance view makes the fork–join and the event log tangible — here an authorization is parked in the parallel intake, held on the 270/271 while CRD and DTR-prefetch have completed:
The instance page also renders a live Mermaid flow diagram — generated from the process definition and overlaid with each state's live status (completed / running / held / skipped) and the current node highlighted. It re-renders as the authorization advances, and any step you add in the DSL appears automatically:
What each screen shows:
- Dashboard — a "start a prior authorization" form (with the AUTO / PEND / DENY / OFFICE-VISIT code hints), a live-polling instance list with color-coded status chips, and the human review queue with Approve / Deny / Rework actions.
- Instance detail — the current state and status, a live Mermaid flow diagram (generated from the process definition, with the current state highlighted) that updates as the instance advances, a parallel-intake panel with a card per branch (in-flight / held / completed), a context-aware action bar (send 271, complete documentation, payer callback, jump → ELIGIBILITY), a vertical event-log timeline with color-coded event badges, and a collapsible folded-data panel.
Build a production bundle with cd web && npm run build. The backend does not need
to be running to build.
Apache 2.0 — see LICENSE.


