Skip to main content

Application Lifecycle

This document describes how an application moves from creation through to a closed/final status in AMS, including the technical steps involved at each stage.


Overview

An application in AMS follows a structured lifecycle driven by two complementary systems:

  • Legacy status (application.status) — a simple internal/external string label used for templateless applications. Managed manually by staff.
  • Workflow engine (WorkflowEngine) — a step-based automation engine that activates when the application has an ApplicationTemplate with a configured workflow. This is the primary path for all template-based applications (e.g. the Mortgage workflow).

For all template-based applications the workflow engine is the authority on status. The legacy manual status dropdown is hidden from the UI when a templateId is present.


Phase 1 — Application Creation (DRAFT)

Actor: Staff (underwriter / admin)

UI: Multi-step wizard at /o/$orgSlug/applications/new

What happens:

  1. Staff selects an ApplicationTemplate (e.g. Demo Mortgage Workflow) and fills in title, type, applicants.
  2. ApplicationService.create() is called.
    • A unique slug is generated via SlugService.
    • copyDocumentsFromTemplate() snapshots all ApplicationTemplateDocument entries into ApplicationDocumentDefinition rows scoped to this application. Each document slot starts with status = PENDING.
    • Template files (e.g. standard forms) are pre-populated as ApplicationDocument rows with status = PENDING.
  3. The application is saved with currentWorkflowStepId = null and currentApplicationStatusId = null.

At this point: The application exists in the database but has not entered the workflow yet. No history rows exist. The workflow card on the overview page shows the flow, but nothing is highlighted.


Phase 2 — Submission (Workflow Start)

Actor: Staff (pressing "Submit" / calling submitApplication mutation)

What happens:

  1. ApplicationService.submit(id) is called.
  2. Calls WorkflowEngine.enterFirstStep(applicationId).
  3. The engine queries all WorkflowStep rows for the template, ordered by sortOrder ASC, and picks the first step (e.g. Document Collection).
  4. enterStep(application, firstStepId, "SYSTEM") runs:
    • Sets application.currentWorkflowStepId = firstStep.id
    • Sets application.currentApplicationStatusId = firstStep.applicationStatus.id (e.g. Docs Collection status)
    • Saves an ApplicationStatusHistory row: previousInternalStatus = null, internalStatus = <statusUUID>, triggeredBy = "SYSTEM"
    • If the first step is of type SYSTEM, immediately calls executeSystemHandler() (auto-advance chains).

At this point: The history table shows 1 row. The flow card highlights the first step.


Phase 3 — Document Collection (SYSTEM step)

Handler: document-completeness-check

What the step does: Waits until all required ApplicationDocumentDefinition slots have status = APPROVED.

Step enters
→ executeSystemHandler("document-completeness-check")
→ DocumentCompletenessCheckHandler.execute(applicationId)
→ loads all ApplicationDocumentDefinitions where required = true
→ checks each: status == APPROVED?
→ if NOT all approved → returns pending("X/Y required docs approved...")
engine stops here; application stays on this step

How documents progress:

EventWhoDocument status change
Client uploads a fileClientApplicationDocument.status = UPLOADED → definition recalculated to UPLOADED
Staff reviews and approvesStaffApplicationDocument.status = APPROVED → definition recalculated to APPROVED
Staff reviews and rejectsStaffApplicationDocument.status = REJECTED

Each reviewDocument() call fires ApplicationDocumentStatusChangedEvent:

reviewDocument(documentId, APPROVED, note)
→ ApplicationDocumentService.reviewDocument()
→ saves ApplicationDocument with status = APPROVED
→ updateDefinitionStatus() ← recalculates parent slot status
→ if any doc APPROVED → definition.status = APPROVED
→ publishes ApplicationDocumentStatusChangedEvent

WorkflowEventListener.onDocumentStatusChanged()
→ workflowEngine.onDomainEvent(applicationId)
→ checks current step is SYSTEM type
→ executeSystemHandler("document-completeness-check")
→ if all required defs are APPROVED → returns satisfied()
→ picks outgoing SYSTEM/BOTH transition → enterStep(next step)

Trigger for advance: The last required document definition being approved automatically moves the application to the next step. No manual staff action needed.


Phase 4 — Credit Report Check (SYSTEM step)

Handler: credit-report-check

What the step does: Runs an external credit check (currently a dummy implementation). The result routes to different next steps based on outcome.

Step enters
→ executeSystemHandler("credit-report-check")
→ CreditReportCheckHandler.execute(applicationId)
→ reads config property: schufa.dummy.result (default: "success")
→ returns satisfiedWithOutcome("success") or satisfiedWithOutcome("error")
→ engine looks for outgoing SYSTEM/BOTH transition whose label matches outcome
→ "success" → transition to e.g. *Client Approval* step
→ "error" → transition to e.g. *Rejected / Manual Review* step

Current state: This handler always immediately resolves (no waiting). The outcome is controlled by the schufa.dummy.result config property. In production this would call an external credit bureau API.


Phase 5 — Client Approval (SYSTEM step)

Handler: client-approval-check

What the step does: Waits until the client explicitly confirms approval via the client portal.

Step enters (status typically = "client-confirmation-waiting")
→ executeSystemHandler("client-approval-check")
→ ClientApprovalCheckHandler.execute(applicationId)
→ checks application.clientApprovalConfirmedAt != null
→ if null → returns pending("Waiting for client approval.")
engine stops; application stays on this step

Client opens their portal → sees "Confirm Approval" card
→ confirmClientApproval mutation
→ ApplicationService.confirmClientApproval(applicationId, currentUserId)
→ verifies caller is a client (via ClientService)
→ sets application.clientApprovalConfirmedAt = now()
→ publishes ClientApprovalConfirmedEvent

WorkflowEventListener.onClientApprovalConfirmed()
→ workflowEngine.onDomainEvent(applicationId)
→ executeSystemHandler("client-approval-check")
→ clientApprovalConfirmedAt != null → returns satisfied()
→ picks outgoing SYSTEM/BOTH transition → enterStep(next step)

Frontend: The confirmation card is shown on the Overview page only when currentWorkflowStep.applicationStatus.statusId === 'client-confirmation-waiting' AND the viewer has role CLIENT.


Phase 6 — Manual Review / Decision (USER steps)

Actor: Staff (underwriter)

What happens: Once automated steps pass, the workflow reaches a USER-type step where staff must explicitly choose the next transition.

Staff opens application → Overview → Workflow card → Flow tab
→ sees advance buttons for all outgoing USER/BOTH transitions
→ clicks e.g. "Approve" or "Request More Info"
→ advanceWorkflowStep mutation → WorkflowEngine.advanceByUser(applicationId, toStepId, userId)
→ validates the chosen toStepId is reachable via USER/BOTH transition from current step
→ enterStep(application, toStepId, "USER")
→ updates currentWorkflowStepId + currentApplicationStatusId
→ saves ApplicationStatusHistory row with triggeredBy = "USER"
→ if the new step is SYSTEM type → immediately runs executeSystemHandler()

Phase 7 — Final / Closed Status

A step is marked isFinal = true in the ApplicationStatus configuration. When the workflow engine enters a final step:

  • currentApplicationStatusId is set to the final status UUID.
  • An ApplicationStatusHistory row is written.
  • If the final step is a SYSTEM step with no outgoing transitions (or a pass-through handler), the engine stops naturally — no further advances are possible.
  • The flow card shows the final step highlighted; no advance buttons are rendered (no outgoing USER transitions exist).

Status History — What Is Recorded

Every call to enterStep() writes one ApplicationStatusHistory row:

FieldValue
previousInternalStatusUUID string of the status the app was on before (null on first entry)
internalStatusUUID string of the new status
workflowStepIdUUID of the WorkflowStep that was entered
triggeredBy"USER" or "SYSTEM"
transitionedAtUTC timestamp

Known limitation: The history table in the frontend currently displays raw UUID strings for From/To columns. A future improvement will join to ApplicationStatus to show human-readable internalName values.


"No Transitions Recorded" — Root Cause

If the History tab shows "No transitions recorded yet", the application has never entered enterFirstStep(). This happens when:

  • The application was created before the submit → workflow wiring was added.
  • The submit mutation was never called (application stuck in DRAFT).
  • enterFirstStep threw an exception (check backend logs) — e.g. no WorkflowStep rows exist for the template.

Fix: Call submitApplication(id) mutation from the application detail (or re-create the application using the same template). This triggers enterFirstStep and populates the history.


End-to-End Diagram (Mortgage Template)

Staff creates application


[DRAFT — no workflow step]
currentWorkflowStepId = null

│ submitApplication mutation

WorkflowEngine.enterFirstStep()


┌─────────────────────────────────────────────────────────┐
│ Step: Document Collection [SYSTEM] │
│ Handler: document-completeness-check │
│ Status: "docs-collection" │
│ │
│ Waiting for: all required ApplicationDocumentDefinition│
│ slots to reach status = APPROVED │
│ │
│ Trigger: ApplicationDocumentStatusChangedEvent │
│ fired by reviewDocument() when staff approves a file │
└─────────────────────────────────────────────────────────┘
│ all required docs APPROVED → satisfied()
│ SYSTEM transition →

┌─────────────────────────────────────────────────────────┐
│ Step: Credit Report Check [SYSTEM] │
│ Handler: credit-report-check │
│ Status: "credit-check" │
│ │
│ Runs immediately on step entry │
│ Outcome "success" or "error" routes to different steps │
└─────────────────────────────────────────────────────────┘
│ outcome = "success" → SYSTEM transition

┌─────────────────────────────────────────────────────────┐
│ Step: Client Approval [SYSTEM] │
│ Handler: client-approval-check │
│ Status: "client-confirmation-waiting" │
│ │
│ Waiting for: application.clientApprovalConfirmedAt │
│ │
│ Trigger: ClientApprovalConfirmedEvent │
│ fired by confirmClientApproval() from client portal │
└─────────────────────────────────────────────────────────┘
│ clientApprovalConfirmedAt set → satisfied()
│ SYSTEM transition →

┌─────────────────────────────────────────────────────────┐
│ Step: Underwriter Review [USER] │
│ Status: "under-review" │
│ │
│ Staff sees advance buttons: │
│ → "Approve" (USER transition) │
│ → "Reject" (USER transition) │
│ → "Request more info" (USER transition) │
└─────────────────────────────────────────────────────────┘
│ │
│ "Approve" │ "Reject"
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Step: Approved │ │ Step: Rejected │
│ [isFinal=true] │ │ [isFinal=true] │
│ Status: "appro- │ │ Status: "rejected" │
│ ved" │ │ │
└──────────────────┘ └──────────────────────┘

Key Technical Files

LayerFileRole
BackendApplicationService.javacreate(), submit(), confirmClientApproval()
BackendWorkflowEngine.javaenterFirstStep(), advanceByUser(), enterStep(), executeSystemHandler()
BackendWorkflowEventListener.javaListens for domain events, calls onDomainEvent()
BackendDocumentCompletenessCheckHandler.javaChecks required docs are all APPROVED
BackendCreditReportCheckHandler.javaDummy credit check; outcome-based routing
BackendClientApprovalCheckHandler.javaWaits for clientApprovalConfirmedAt
BackendApplicationDocumentService.javareviewDocument() fires ApplicationDocumentStatusChangedEvent
BackendApplicationStatusHistory.javaAudit table — one row per workflow step entry
Frontenduse-application-workflow.tsAll workflow GraphQL hooks
Frontendapplication-workflow-flow.tsxRead-only ReactFlow + advance buttons
Frontendapplication-workflow-history.tsxHistory table
Frontendoverview/index.tsxMounts workflow card; client approval card