Skip to content
All posts
ConfigurationDevelopers

Workflows in depth: lists, dispatch, connectors and the audit trail

Rows and lists, configuration read as data, choosing what runs at run time, short-period proration, calling outside systems, and the trace that explains every price.

The BimaStack team · · 10 min read

The parts of the workflow engine that real products lean on hardest: rows and lists, reading configuration as data, choosing what to run at run time, calling outside systems, and the versioning and trace that make every price explainable. The syntax of each is in the reference.

Rows and lists

A record is a named row shape: a traveller, a family member, a rate row. A port can carry one record, or a list of them. A formula reads a record’s fields as row.rate; a bare record can’t be used in arithmetic.

A list of members, filtered, counted and loaded (extract)
record Member {
    field memberClass : ENUM("PRINCIPAL", "SPOUSE", "CHILD", "PARENT", "PARENT_IN_LAW")
    field age : NUMBER
}

transform children {
    op: FILTER
    elements: member : LIST<RECORD:Member>
    expression: member.memberClass == "CHILD" and member.age < 21
}
transform childCount {
    op: COUNT
    elements: member : LIST<RECORD:Member>
}
formula childLoading {
    expression: max(count - 4, 0) * 1500
}

connect members -> children.member
connect children.member -> childCount.member
connect childCount.count -> childLoading.count
  • Several lists zip by position. A FILTER over ages, flags and names takes item 1 of each together, then item 2. The filtered output keeps each list in step.
  • Context is one value given to every item: a deductible, a threshold, a base rate.
  • MAP can run a block per item, not just a formula. A block that prices one rate row (strategy, minimum, proration) runs once for each row of a pass.
  • FIRST_MATCH keeps the first item that matches and stops the run if none does. It is how a band table finds its band.
  • MAX and MIN stop on an empty list. The largest age of nobody isn’t zero, and a silent zero would let an empty list pass a “nobody over 70” rule.

Reading configuration as rows

A grouplookup reads published configuration into a list of records, so a whole table of settings becomes data a transform can work over. It never knows what the settings mean: which fields to take is written in the workflow.

  • Group mode: every setting of a kind whose key starts with a prefix, one record each, in key order. A field can come from the key itself.
  • Array mode: one setting found by a wired key, its array exploded into records in their own order: the rows of a rate table, the day bands of a short-period scale.
  • Indexed fields read one position of a list field, which is how a rate table’s keys come out one by one.
  • Defaults fill a field an item doesn’t carry, including a value set once on the enclosing setting (a day-count convention that applies to every band).
The platform’s own two-key banded rate block, in full
record TableRateRow {
    field k1 : STRING
    field k2 : STRING
    field k3 : STRING
    field bandFrom : NUMBER
    field bandTo : NUMBER
    field rate : NUMBER
}

formuladef "protected:table-rate:2d-band:match" {
    in row : RECORD:TableRateRow
    in key1 : STRING
    in key2 : STRING
    in x : NUMBER
    expression: row.k1 == key1 and row.k2 == key2 and x >= row.bandFrom and x < row.bandTo
}

blockdef "protected:table-rate:2d-band" {
    param tableCode : STRING
    input key1 : STRING
    input key2 : STRING
    input x : NUMBER

    grouplookup tr2dbRows {
        kind: RATE_TABLE
        key: from tableCode
        array: "rows"
        row: TableRateRow
        project k1 from "keys" index 0 default "-"
        project k2 from "keys" index 1 default "-"
        project k3 from "keys" index 2 default "-"
        project bandFrom from "from"
        project bandTo from "to"
        project rate from "rate"
        out rows
    }
    transform tr2dbMatch {
        op: FIRST_MATCH
        elements: row : LIST<RECORD:TableRateRow>
        context: key1 : STRING
        context: key2 : STRING
        context: x : NUMBER
        binding: formula "protected:table-rate:2d-band:match"
    }
    recordfields TR from tr2dbMatch : RECORD:TableRateRow
    output result {
    }

    connect tableCode -> tr2dbRows
    connect tr2dbRows -> tr2dbMatch.row
    connect key1 -> tr2dbMatch
    connect key2 -> tr2dbMatch
    connect x -> tr2dbMatch
    connect TR_rate -> result.rate
}

Nothing about rate tables is built into the engine. A block like this is ordinary workflow text; a table with five keys needs one more block, not a change to the platform.

Choosing what runs, at run time

A lookup with a dynamic key finds its content by a prefix plus a value worked out in the run. The step it feeds is fixed; what that step runs varies.

A no-claim discount per tier: one formula per tier, chosen by the quote
formuladef "ncd:GOLD" {
    in runningTotal : NUMBER
    expression: runningTotal * 0.30
}
formuladef "ncd:SILVER" {
    in runningTotal : NUMBER
    expression: runningTotal * 0.15
}
formuladef "ncd:NONE" {
    in runningTotal : NUMBER
    expression: runningTotal * 0
}

lookup L_ncd {
    kind: NODE_BINDING
    key: "ncd:" from ncdTier
}
formula F_ncd {
    in runningTotal : NUMBER
}
connect ncdTier -> L_ncd
connect L_ncd -> F_ncd.$binding
  • Why not a chain of decisions? Two branches can’t both feed one input, and one choice may need a block where another needs a formula. A dynamic lookup has neither limit.
  • Blocks too. The same works for a block: "period:" + "SHORT" or "FULL" picks between two blocks with identical ports.
  • A new tier is configuration. Add "ncd:PLATINUM" and the workflow prices it.
  • Checked at run time. A dynamic key isn’t known until the run, so its content is checked when it is found; a missing one stops the run.

Short-period proration

prorate() does full-term and linear proration in a formula. A short-period scale (cover of up to 10 days charged 40% of the annual premium) is a rate table banded by days, whose rate is the percentage: count the days, read the band, apply it.

A short-period scale from a days-banded rate table
workflow "short-period" {
    input annualPremium
    input coverFrom : STRING
    input coverTo : STRING

    formula coverDays {
        expression: daysBetween(coverFrom, coverTo) + 1
    }
    block scale {
        in x : NUMBER
        out rate : NUMBER
        param tableCode = "short-period-scale"
        binding: block "protected:table-rate:band"
    }
    formula shortPeriodPremium {
        expression: annualPremium * rate / 100
    }
    output result {
    }

    connect coverFrom -> coverDays
    connect coverTo -> coverDays
    connect coverDays -> scale.x
    connect annualPremium -> shortPeriodPremium
    connect scale.rate -> shortPeriodPremium.rate
    connect shortPeriodPremium -> result.premium
}

Count both ends (+ 1) or not to match how the insurer’s card is written. Keyed by plan instead, the same scale is a table-rate:1d-band block.

Calling outside systems

A fetch step calls an HTTP service or an outside database. What to reach and what to call are two separate pieces of configuration, each drafted, approved and dated like any other.

Piece Holds Set up by
ConnectionHow to reach a system: HTTP or DB, the base URL or JDBC URL, a timeout, and a reference to its credentialsAn administrator, once per environment
OperationWhat one call does: for HTTP a method, path, headers and body; for DB a query. Plus which field of the answer to take, and its typeWhoever builds the integration
A connection and an HTTP operation
{ "type": "HTTP",
  "target": "https://valuations.example.com",
  "timeout": "PT5S",
  "credentialReference": "connectors/valuations" }

{ "operationType": "HTTP",
  "method": "GET",
  "pathTemplate": "/v1/vehicles/{registration}/value",
  "headerTemplate": { "Accept": "application/json" },
  "bodyTemplate": null,
  "resultField": "marketValue",
  "resultType": "NUMBER" }
  • Placeholders like {registration} are filled from the fetch step’s inputs: URL-encoded in a path, and passed as bound parameters in a database query, never pasted into SQL.
  • Credentials never sit in configuration. A connection names where they are kept; the secret is read when the call is made. An HTTP call with a stored token sends it as a bearer token unless the operation sets its own Authorization header.
  • The answer is narrowed to one typed field, the top-level JSON field or the first row’s column. Only that value is kept for the audit, never the whole response.
  • Once per run. A fetch result is reused for the rest of the calculation, so one quote sees one consistent answer.
  • Fail closed. A timeout, an error status or a missing field stops the calculation. A quote is never priced on a guess.
  • Outside systems only. A database connection is for someone else’s database. BimaStack’s own data is read through lookups, which respect scope and dates.

For currency, prefer convert(): it uses the published, audited exchange rate, the same one for the whole run. Use a fetch for a genuinely live value that needs no audit trail.

Blocks, more closely

  • Ports must match exactly. A call declares the block’s inputs and outputs by name and type; a mismatch is BLOCK_PORT_MISMATCH. Labels and descriptions don’t count.
  • Six levels at most, and never back to itself, however indirectly.
  • Platform blocks ship with every release, such as the table-rate family. Call them; don’t copy them.
  • Links a block needs. A block may look up formulas your product must supply, such as a base premium per tier. The editor lists them and warns when your scope hasn’t linked one.
  • Extracting a selection turns it into a new draft block and gives you the step that replaces it. A selection that would split a binding or a decision’s guard across the edge is refused.

Versions and the audit trail

Workflows, blocks, connections and operations are all versioned configuration, with one lifecycle: draft, approve by a second person, publish from a date, retire. A published version never changes; an edit is a new version.

The trace records, per step
Inputs and outputsAt full precision
SkippedAnd which branch a decision took
Settings readEvery rate, formula link and block link, with its version
Enclosing blockFor a step inside a block, at any depth; a MAP over a block tags each item’s run
Per itemFor FILTER and MAP, every item’s inputs and result

Run the same workflow for a past date and it reads what was live then: the same workflow, block, formula and rate versions. That is what lets a quote from last year be recalculated and explained today.

The definitions API

Call Does
GET /api/workflow/definitions/{kind}kind is workflow, block, connector-connection or connector-operation. Versions a page at a time, filtered by status and scope
POST /api/workflow/definitions/{kind}Save a draft: {scope, key, value, effectiveFrom, effectiveTo, expectedCurrentVersion, reason}
POST …/{id}/approve · publish · retireLifecycle; approving your own draft is a 403, publishing an unapproved one a 409
POST /api/workflow/validateCheck a graph; with a scope and date, its blocks too
POST /api/workflow/simulateRun an unsaved graph against real settings
POST /api/workflow/executeRun a published workflow by key, in a scope, on a date
GET /api/workflow/node-typesEvery step kind’s ports and fields, to drive an editor
GET /api/workflow/blocks/{key}/portsA block’s own ports in a scope
POST /api/workflow/blocks/extractMake a draft block from a selection

Integrating a system of your own?

Talk to us about connectors for valuations, registries or claims history.

Book a demo