Skip to main content

Lifecycle — workflow execution

This page is the Temporal execution lens: how an onboarding workflow execution behaves at runtime inside the worker — the parent/child execution tree, activity executions and their retries, durable replay, cancellation propagation, and terminal execution statuses.

It is distinct from the business onboarding journey (INITIATED → IN_PROGRESS → PENDING_ACTION → COMPLETED / FAILED) that a caller sees, which lives in the Ark Flows API lifecycle. One run has both: a business status the workflow reports through its state, and an execution status Temporal tracks.

The execution tree

A single onboarding is one parent execution that spawns up to two child executions, all on the same task queue, all hosted by this worker. The parent is one of three flavours — they share the KYC child and select between two provisioning children:

The parent runs the KYC child first; only if KYC is VERIFIED and the risk gate passes (LOW) does it run the provisioning child. Child workflow ids are deterministic (kyc-{workflowId}, provisioning-{workflowId}), so each parent has at most one of each — and the two provisioning workflows share the same id slot because at most one of them runs per parent.

Evidence: onboarding-{individual,sole-trader,company}.workflow.tsexecuteChild(kycVerificationWorkflow, …) (via the _shared/pre-provisioning.ts trunk), executeChild(provisioningWorkflow, …) (individual + sole-trader), executeChild(companyProvisioningWorkflow, …) (company).

Execution status machine

Temporal tracks every workflow execution (parent and each child independently) through these statuses:

Business failure is normally an execution Completed. The parent workflow catches its own step errors (and the KYC/risk/address rejection paths) and returns an OnboardingState with status: 'FAILED' rather than throwing. So a rejected applicant ends as execution status Completed carrying state.status = 'FAILED'. Execution Failed is reserved for an error that escapes the workflow body entirely.

Evidence: onboarding-{individual,sole-trader,company}.workflow.ts — each has the same outer try/catch that returns state on error; the shared _shared/pre-provisioning.ts trunk sets state.status = 'FAILED' and returns { kind: 'halt' } for the KYC, risk-gate, and missing-address branches, on which the parent returns state.

Activity executions, retries, and timeouts

Each step the workflow runs is a separate activity execution that the worker picks up off the task queue, runs (making the real HTTP call), and reports back. Per-activity timeout and retry policy is configured on the workflows' proxyActivities in the library, not in this worker — the worker only executes whatever policy the workflow scheduled.

The policies currently set in @digizoo/ark-flows-definitions (start-to-close timeout + retry):

Activity groupStart-to-closeMax attemptsNon-retryable
getToken30s5
lookupAddress, validateAddress30s3
initiateKyx, uploadDocument60s2ArkBadRequestException
scoreRisk30s3
updateWorkflowStatus15s5
provisionParty60s3ArkBadRequestException
provisionAccount + addSignatories (same proxy)60s3ArkBadRequestException
provisionCard60s3ArkBadRequestException

(maximumAttempts is the total attempt count including the first; backoffCoefficient: 2 on all.) Evidence: the proxyActivities<…>({ startToCloseTimeout, retry }) blocks in workflows/parents/_shared/pre-provisioning.ts (the trunk activity policies shared by all three parents), kyc-verification.workflow.ts, provisioning.workflow.ts, and company-provisioning.workflow.ts.

A side-effectful activity marked ArkBadRequestException non-retryable stops on a 4xx: a bad request will not double-create a KYX check or a party. Idempotent reads retry on transient errors.

Runtime timeline of one execution

Status reads (GET /v1/onboarding/{flavour}/{workflowId}/status) do not flow through Temporal. The API reads the row that the worker's updateWorkflowStatus activity archived to ark-flows DB on each step transition — the DB is the source of truth, which lets status survive Temporal's 90-day event-history retention.

Every parent workflow also registers a Temporal getState query handler over its live in-memory OnboardingState (getStateQuery = defineQuery<OnboardingState>('getState') and setHandler(getStateQuery, () => ctx.state) in each parent), but no consumer currently calls it — it is available for ad-hoc operational inspection via tctl / a Temporal client, not on the public read path.

Evidence: safeArchive calls updateWorkflowStatus in workflows/parents/_shared/pre-provisioning.ts; the per-flavour {Flavour}WorkflowService.getStatus in ark-flows-api reads via WorkflowStateRepository.findByWorkflowId, not via the Temporal client.

KYC child polling window

The KYC child (kycVerificationWorkflow) polls getKyxStatus on a tiered schedule and is the reason the parent can sit in PENDING_ACTION for days:

  • first 10 minutes: poll every 1 minute (catches fast-path "instant pass" outcomes);
  • after 10 minutes: poll every 1 hour (manual review runs at human time);
  • total cap: 3 days — if no terminal status by then, the child returns finalStatus: 'TIMEOUT' and the parent transitions to FAILED.

Terminal platform statuses map to VERIFIED / DECLINED / ERRORED; anything else (e.g. UNDER_REVIEW) keeps polling. Evidence: FAST_POLL_WINDOW_MS, SLOW_POLL_INTERVAL, TOTAL_TIMEOUT_MS, and mapToTerminalStatus in kyc-verification.workflow.ts.

Durability and replay

Temporal persists each execution's event history. A worker restart mid-run does not lose progress: the workflow code is replayed deterministically from history up to the last recorded point, then resumes. This is why workflow code may not import activity implementations or perform direct side effects — all I/O goes through activities so it can be recorded and replayed. The event-history payloads are encrypted at rest by the worker's data converter (see the architecture notes).

Cancellation

The KYC child (spawned from the shared trunk) and the provisioning child (individual / sole-trader's provisioningWorkflow or company's companyProvisioningWorkflow) are all spawned with cancellationType: WAIT_CANCELLATION_COMPLETED. Cancelling the parent propagates the cancellation into the running child and waits for the child to finish unwinding before the parent's cancellation completes — so a cancel never abandons an in-flight child execution. Evidence: ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED on the executeChild calls in workflows/parents/_shared/pre-provisioning.ts (KYC) and in each parent workflow (provisioning).

Worker shutdown vs. execution lifecycle

A SIGTERM / SIGINT to the worker triggers worker.shutdown(): the worker stops accepting new tasks and lets in-flight activities drain. This does not cancel or fail the executions — they are durable and resume on the next available worker. Evidence: the signal handlers and worker.run() / worker.shutdown() in src/main.ts.