Skip to content

About

FHIR-native prior-authorization workflow engine — a reference implementation of orchestration, async holds, human-in-the-loop review, and event sourcing for CMS-0057 / Da Vinci (CRD·DTR·PAS). Kotlin + Spring Boot.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

FHIR Prior-Authorization Workflow Engine

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:

Prior-Auth Engine operator console — dashboard, live instances, and review queue


Prior auth is a workflow problem, not a form problem

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.

Architecture

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 is signalled 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 HumanTask on a queue; resolving the task signals the workflow with the reviewer's decision.
  • Straight-through vs pended. An AUTO procedure sails through to approval with no human; a PEND procedure 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
Loading

Diagrams (in docs/images/):

  • prior-authorization-workflow-lifecycle.png — the end-to-end lifecycle
  • prior-authorization-orchestrator-capabilities.png — orchestrator vs capabilities
  • prior-authorization-request-state-machine.png — the request state machine

Framework features

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 registered allowJump; 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 a StepStatus (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.

Concurrency & scale

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 layout

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.

Quickstart

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)

Run the offline CLI scenarios

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.

Run the HTTP demo

./gradlew :demo:bootRun

Then 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"}'

How it maps to CMS-0057 / Da Vinci

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.

Not production (an honesty note)

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.

Operator UI

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 dev

Then 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:

Instance detail — the parallel-intake fork–join and the color-coded event-sourced log

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:

Live Mermaid flow — the process diagram with the current state highlighted, updating in real time

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.

License

Apache 2.0 — see LICENSE.

About

FHIR-native prior-authorization workflow engine — a reference implementation of orchestration, async holds, human-in-the-loop review, and event sourcing for CMS-0057 / Da Vinci (CRD·DTR·PAS). Kotlin + Spring Boot.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages