Fraud & Fraud AI
Flows and Resolution

Flows and Resolution

A fraud decision does not come from a fixed rule set — it comes from the flows you publish. You scope a flow by operation type (card payment, bank transfer, wallet withdrawal…); when a decision request arrives the system resolves every matching published flow, runs them and merges the results into a single decision.

This page explains how resolution works — the answer to "which flows did my request land on?". For the endpoint contract see Decision API.

The endpoint that triggers your rules

The flow you published — and the rules inside it — are triggered by a single call:

POST /api/v1/fraud/decisions/evaluate
Authorization: Bearer <access-token>

You mint the token from POST /api/v1/auth/token with your Ed25519 signature. Required scope: fraud:view_decisions. Full field dictionary: Decision API.

Which request field drives which dimension

You steer the resolution — which flows run is derived from what you send:

Resolution dimensionWhere it comes from
merchantIdFrom your token — you do not send it in the body
merchantGroupIdsFrom your token context
subjectTypeThe operation type in the body (payment, bankTransfer, billPayment … whichever object you fill)
productContextIn the body; defaults to direct_api
integrationModeIn the body
Flow namemetadata.fraudFlowName (or liveFraudFlowName / flowName)
curl -X POST "https://api-gateway.lunixi.com/api/v1/fraud/decisions/evaluate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "bankTransfer": { "amount": 250000, "currency": "TRY", "iban": "TR33…" },
    "customer": { "id": "cust_84213" },
    "productContext": "direct_api"
  }'

This call finds every flow you scoped to the bank_transfer operation type, runs them and returns the merged decision. Which flows ran and which rules matched come back in the explainability fields and the multiFlow block — that is where you read why the decision was made.

For history-based rules (velocity, baseline deviation) you must also feed events: POST /api/v1/fraud/events/transactions (single) and /batch. The decision call looks at one transaction; "this customer made 12 transactions in the last 24 hours" comes from the event backbone. Those endpoints are Ed25519 signed, not Bearer.

The dimensions a flow is scoped on

A flow can be narrowed on the dimensions below. A dimension left empty means "any" — it matches every value on that axis.

DimensionExampleIf left empty
merchantIdA specific merchantGlobal flow — matches every merchant
merchantGroupIdsA list of merchant groupsNo group matching
flowTypefraud_decision—
subjectTypecard_payment, bank_transfer …Matches every operation type
integrationModeIntegration modeMatches all
productContextdirect_api (default)Matches all

subjectType carries the operation type: card_payment · bank_transfer · eft · fast · crypto_transfer · wallet_deposit · wallet_withdrawal · wallet_payment · wallet_transfer · bill_payment · loan · payout · refund · cash_in · cash_out · international_transfer

Only published and isActive flows enter resolution. A draft or inactive flow is never considered, however narrow its scope. On the decision path, only triggerType='inline_request' flows run: flows triggered by events, kafka or a schedule run on their own triggers and never enter the decision path — otherwise the same flow would execute twice.

Resolution: every matching flow runs

The scope dimensions are unchanged — merchantId · merchantGroupIds · flowType · subjectType · integrationMode · productContext (an empty column matches everything). What changed is the selection step: the system no longer picks the single narrowest flow. Every published flow that passes the filter is resolved and runs in one of two lanes, chosen by its executionMode: sync flows produce the decision response, async flows run in the background without delaying the decision.

Multi-flow resolution and the sync/async lanesWhen a decision request arrives, published and active flows are filtered on six dimensions. Unlike the old behaviour no single flow is selected: EVERY flow passing the filter is resolved. Flows marked executionMode sync run independently and their results merge under a most-severe-wins rule to produce the decision response; flows marked async are queued without delaying the decision and run in the background.EVERY MATCHDecision requestPOST /fraud/decisions/evaluateFilter — 6 dimensionspublished + isActiveSync laneindependent runs · zero baselineexecutionMode syncAsync lanequeued · does not delay the decisionexecutionMode asyncMergemost severe winsscore cap · fail-closed · budgetOne decisionmultiFlowAsync runadvisory — does not change it
The filter is unchanged; what changed is the selection step. Scope precedence no longer picks one flow — every matching flow runs. The sync ones merge into the decision, the async ones run in the background without delaying it.

The sync lane: independent runs, one decision

Every executionMode='sync' flow runs independently on a zero base state: flow order does not affect the result, and no flow sees another flow's score or reason codes. Once they finish, the outcomes are merged into a single decision:

DimensionMerge rule
DecisionThe most severe wins: allow < force_3d < review < block
ScoreA per-flow cap (default 100) plus a total cap (default 250). When a cap kicks in, the response carries the multi_flow_score_capped reason code
Flow failureThe other flows keep running; the failed flow enters skippedFlows and the decision is escalated to at least review (reason code flow_failed_fail_closed:<flow-name>). An existing block is never downgraded
Time budgetDefault 25 s per request. When the budget runs out, remaining flows do not run and are listed in skippedFlows with reason budget_exceeded

Three levels of time limit

The time budget is not one layer but three nested limits, and the outer one bounds the inner:

LevelDefaultIf exceeded
Node8sThat node errors
Flow20sThat flow is skipped and lands in skippedFlows
Request (all flows)25sRemaining flows do not run — budget_exceeded

If a node sets its own timeoutMs it is used — but it cannot exceed the flow deadline; the node is bounded by whatever time is left.

🚫

A node timeout is a node error. With continueOnError set it takes the error branch; without it, the flow's fail-closed posture applies and the decision rises to at least review.

The practical consequence: a slow external service behind an http_request or grpc_call node can affect not just that node but the whole decision. Giving outbound nodes their own timeoutMs puts that behaviour under your control.

⚠️

A flow that did not run is not allow. A flow skipped over budget is distinguishable from a flow that ran and came back clean through the skippedFlows field — when you build monitoring, count the budget_exceeded reason separately.

The flowPath in the explainability output is now flow-prefixed: each step comes as flow:<flow-name>/<node-id> — you can read from the response which node belongs to which flow.

The async lane: never delays the decision

executionMode='async' flows do not hold up the decision response: the run is queued, shows up in the response's asyncFlows list with status queued, and executes in the background on the same engine. The result is written to its run record — see Async result delivery below.

An async result is advisory: it never retroactively changes the decision that was already returned synchronously. The flow's side effects — opening cases, running actions — apply normally.

The multiFlow block in the response

The decision response gained an additive field — no existing field changed:

{
  "multiFlow": {
    "enabled": true,
    "executedFlows": ["card-velocity-rules", "device-reputation"],
    "skippedFlows": [
      { "name": "heavy-analysis", "reason": "budget_exceeded" }
    ],
    "asyncFlows": [
      { "name": "case-sweep", "status": "queued" }
    ]
  }
}

On the gRPC wire the same block travels as a JSON string in the multiFlowJson field.

Calling a specific flow by name

If the request carries a flow name in metadata, resolution locks onto that name — multi-flow resolution runs only the named flow for that request:

{ "metadata": { "fraudFlowName": "yuksek-riskli-havale" } }

Accepted keys: liveFraudFlowName, fraudFlowName, flowName. This is how you exercise canary or trial flows — do not use it to override the default in production.

Async result delivery

The result of an executionMode='async' flow is written to its run record.

Delivery of those results to you will run on platform webhook events — coming soon. For platform webhook setup, signature verification and retry behaviour, see Webhook setup.

Missing/malformed data in switch nodes: condition vs op

A switch case can be written in two forms, and their behaviour on missing or malformed operands (e.g. text where a number is expected) is deliberately different:

Case formOn missing/malformed operandWhy
condition (full rule DSL)The case counts as matched and that branch is taken (fail-closed)Rule-engine security doctrine: a type-manipulated request must not silently bypass a block-side control
Simple op value match (eq, gt, in...)The case is skipped, the flow takes the default branch, and it is surfaced in indeterminateCases (fail-safe)Cheap routing matches should not lock the flow on missing data — but it must stay visible

Flow-author rule: write decision-critical (block-side) checks with condition — you want fail-closed. Write cheap routing matches with op — missing data falls to default and stays visible via indeterminateCases.

Inbound webhook trigger (triggerType: webhook)

A webhook-triggered flow is fired from outside through an event-stream connection profile. The chain:

  1. Define a connection profile in the panel (source: webhook; the signature secret lives on the profile).
  2. The external system POSTs to POST /api/v1/fraud/database-events/{connectionProfileId}/webhook/{routingKey} — the signature travels in the x-event-signature header, the timestamp in x-event-timestamp.
  3. The signature is verified (HMAC + time window); then every published flow whose triggerType is webhook or event and whose triggerConfig.webhookPath equals the routingKey is executed.

This inbound trigger only runs the flow; its signature secret lives on the connection profile.

Choosing an executionMode

ModeUse it for
syncFast checks that shape the decision itself — velocity rules, thresholds, list checks
asyncHeavy analysis, reporting and case flows — anything that must not add to decision latency

When nothing matches

🚫

An unconfigured merchant does not silently get allow. If no capability bundle, rule group or flow resolves, the request is tagged with the fraud.decision.unconfigured reason code and falls back to the policy default (FRAUD_UNCONFIGURED_DECISION, defaulting to review).

This is deliberate: the earlier behaviour could not tell an unconfigured merchant apart from an explicitly cleared payment. Watch the fraud.decision.unconfigured reason code while you integrate — you will see it in production if your flow never went live.

Capability bundles

Flows also run on their own, but there is a capability bundle layer above them: a bundle describes which capabilities run for a decision, in what order and under which failure policy, orchestrating several flows where needed.

SituationWhat runs
A capability bundle resolves for the merchantThe bundle runs and orchestrates the flows
No bundleThe matching published flows run directly under multi-flow resolution
Neitherfraud.decision.unconfigured → policy default

If a flow errors mid-run the decision fails closed: the evidence gathered so far (capability, rule groups, live signals) stays valid and the decision is escalated to at least review — an existing block is never downgraded.

Running your own code inside a flow

Rule nodes are enough to express a threshold or a comparison, but some policies cannot be written in a rule language — computing a score with your own formula, parsing an external response, a transformation specific to your business. For those you place a script node in the flow and run your own JavaScript.

Script asset lifecycle

StageWhat happens
DraftSource code, input/output schema, time and memory ceilings are defined
Static analysisA mandatory pre-publish gate. Patterns such as import / export, require() and eval are blocked. Result: clean · warning · blocked
PublishedOnly code that passes analysis can be published; a source digest (sourceHash) is recorded
DisabledWithdrawn from publication; flows can no longer call it

How it runs

The code does not run inside the fraud service but in a separate execution service: inside a real V8 isolate (isolated-vm), with its own heap ceiling and its own time limit. Isolates are recycled after a set number of jobs, and output and log sizes are capped.

⚠️

Your script cannot reach the network, touch the file system, or load another module. If it needs outside data, fetch it with the flow's own nodes (http-request, database-lookup, redis-get) and pass it in as input.

Two modes

ModeUse
sync_transformPure transformation — take input, return a value. Preferred on the hot path
async_scriptLonger-running logic that needs to await something

Every execution is audited: success · failed · timeout. A script that exceeds its time ceiling does not stall the flow — it returns as an error and the flow's fail-closed posture takes over.

Related

  • Decision API — the POST /api/v1/fraud/decisions/evaluate field dictionary
  • Decision logs — GET /api/v1/fraud/decision-logs