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.ts —
executeChild(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 group | Start-to-close | Max attempts | Non-retryable |
|---|---|---|---|
getToken | 30s | 5 | — |
lookupAddress, validateAddress | 30s | 3 | — |
initiateKyx, uploadDocument | 60s | 2 | ArkBadRequestException |
scoreRisk | 30s | 3 | — |
updateWorkflowStatus | 15s | 5 | — |
provisionParty | 60s | 3 | ArkBadRequestException |
provisionAccount + addSignatories (same proxy) | 60s | 3 | ArkBadRequestException |
provisionCard | 60s | 3 | ArkBadRequestException |
(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
ArkBadRequestExceptionnon-retryable stops on a4xx: 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 toFAILED.
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.