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
Connection
How to reach a system: HTTP or DB, the base URL or JDBC URL, a timeout, and a reference to its credentials
An administrator, once per environment
Operation
What 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 type
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 outputs
At full precision
Skipped
And which branch a decision took
Settings read
Every rate, formula link and block link, with its version
Enclosing block
For a step inside a block, at any depth; a MAP over a block tags each item’s run
Per item
For 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 · retire
Lifecycle; approving your own draft is a 403, publishing an unapproved one a 409
POST /api/workflow/validate
Check a graph; with a scope and date, its blocks too
POST /api/workflow/simulate
Run an unsaved graph against real settings
POST /api/workflow/execute
Run a published workflow by key, in a scope, on a date
GET /api/workflow/node-types
Every step kind’s ports and fields, to drive an editor
GET /api/workflow/blocks/{key}/ports
A block’s own ports in a scope
POST /api/workflow/blocks/extract
Make a draft block from a selection
Integrating a system of your own?
Talk to us about connectors for valuations, registries or claims history.