Every declaration, step, port type and wiring form of the workflow text language, how formulas become versioned links, every check, and the validate, simulate, import and export endpoints.
The BimaStack team · · 12 min read
The complete text form of a BimaStack workflow: every declaration, every step, every port type and wiring form, how formulas become versioned bindings, what is checked, and the endpoints that validate, simulate, import and export a document. New to workflows? Read the guide first.
A document
One file holds at most one workflow, plus any number of records, named formulas and blocks, in any order. The text compiles to exactly the graph the canvas edits; there is no second engine.
Top-level declarations
// a line comment /* a block comment */
record Traveller { ... } // a named row shape
formuladef "travel-age-factor" { ... } // a reusable formula, by key
blockdef "my-ncd" { ... } // a reusable sub-workflow, by key
workflow "travel-pricing" { ... } // the workflow itself (at most one)
Names of nodes and ports are identifiers. Anything else (a hyphen, a dot, a reserved word) is written in double quotes: input "vehicle-value-usd" : NUMBER, input "underwriting.loadingPercent" : NUMBER.
Keys of workflows, formuladefs and blockdefs are quoted strings. Platform-owned keys start protected:.
Strings have no escapes, so a string can’t contain a double quote.
A run with any other value fails at the input. It connects only to an ENUM port with identical values, in order (not to a STRING port), and reaches a formula as a string
record RateRow {
field rateValue : NUMBER
field rowCode : STRING
field loadable : BOOLEAN // fields are scalar: NUMBER, STRING, BOOLEAN, ENUM(...)
}
formuladef "ks-age-factor" {
in driverAge : NUMBER
expression: """
let senior = if(driverAge > 70, 1.10, 1.0);
if(driverAge < 25, 1.15, senior)
"""
}
blockdef "ks-param-block" {
input amount // public input port
param surcharge : NUMBER // set by the caller, not a port
param label : STRING
aggregate pbSum {
sign: ADD
}
output pbOut { // exactly one output: its ports are the block's outputs
}
connect amount -> pbSum.currentTotal
connect surcharge -> pbSum.contribution
connect pbSum -> pbOut.total
connect label -> pbOut.label
}
expression: takes the rest of the line, or a """ block over several lines with the common indentation removed, as in a Java text block. Because it takes the whole line, a comment can’t follow an expression on the same line: it would be read as part of the formula. A block’s inputs are its input nodes and its outputs are its one output node’s ports. A param (NUMBER, STRING or BOOLEAN) is read by name inside the block but is not a public port.
The steps
input
input
input sumInsured // NUMBER, output port "value"
input usage : ENUM("PRIVATE", "COMMERCIAL", "PSV")
input travellers : LIST<RECORD:Traveller>
input riskScore : NUMBER out score // rename the output port
formula
formula
formula F_ageLoad { // inputs inferred from what is connected
expression: runningTotal * (factor - 1)
}
formula F_over {
in oldest : NUMBER // or declared
out flag : BOOLEAN // NUMBER (default), BOOLEAN or STRING
expression: oldest > 70
}
formula F_ageFactor {
in driverAge : NUMBER
binding: formula "ks-age-factor" // run a formula saved under a key
}
formula F_ncd {
in runningTotal : NUMBER // no binding: wired through $binding instead
}
The formula must return exactly the declared output type; a mismatch stops the run, never coerces. An output declared BOOLEAN is how a stage raises a rule flag.
decision
decision, true -> and otherwise ->, guard:
decision D_windscreen {
in hasWindscreen : BOOLEAN
expression: hasWindscreen
otherwise -> F_windscreenNil // runs on false
}
decision D_young {
in driverAge : NUMBER
binding: formula "ks-is-young"
true -> F_youngLevy // runs on true
}
formula F_windscreenCharge {
expression: 30 * usdKes
guard: D_windscreen.true // or D_windscreen.false
}
The branch not taken is SKIPPED, and so is any step fed only by a skipped step. Give each branch its own output; the ports of every output that ran merge into one result. A decision’s branch is control, not data: it can’t be wired as a value.
lookup
lookup: static and dynamic keys
lookup L_vat {
kind: NODE_BINDING
key: "ks-vat" // one fixed key
}
lookup L_ncd {
kind: NODE_BINDING
key: "ks:ncd:" from ncdTier // prefix + the value of the wired port
}
connect ncdTier -> L_ncd
connect L_ncd -> F_ncd.$binding
A lookup finds one configured item in the run’s scope on its date: a formula or block binding (NODE_BINDING), a connection (CONNECTOR_CONNECTION) or an operation (CONNECTOR_OPERATION). Its output is not an ordinary value: it can only feed a $binding, $connection or $operation port. A dynamic key picks different content per run (ks:ncd:GOLD, ks:ncd:SILVER) while the step it feeds stays the same kind.
grouplookup
grouplookup: group mode and array mode
grouplookup levyTables { // group mode: every table whose code starts "levy:"
kind: RATE_TABLE
keyPrefix: "levy:"
row: TableName
project code from key // the setting's own key, prefix removed
project name from "name" default "-"
out tables
}
grouplookup rows { // array mode: one table, its rows; keys by position
kind: RATE_TABLE
key: from tableCode
array: "rows"
row: TableRateRow
project k1 from "keys" index 0 default "-"
project rate from "rate"
out rows
}
Either way the result is a LIST<RECORD:…>, an ordinary value a transform can work over. Group mode orders rows by key; array mode keeps the array’s order. A default fills a field the item lacks (in array mode, also from the enclosing setting).
transform
op
Element ports
Formula
Output
COUNT
exactly one LIST
none
count
SUM, MAX, MIN
exactly one LIST<NUMBER>
none
sum, max, min. MAX and MIN fail on an empty list
FILTER
one or more, zipped by index
BOOLEAN, per item
one filtered list per element port, same name
MAP
one or more, zipped by index
NUMBER per item, or a block
mapped: LIST<NUMBER>
FIRST_MATCH
one or more, zipped by index
BOOLEAN, per item
the first matching item itself; no match stops the run
transform
transform T_eligible {
op: FILTER
elements: age : LIST<NUMBER> // several lists, zipped item by item
elements: name : LIST<STRING>
context: minAge : NUMBER // the same value for every item
expression: age >= minAge and name != "SPAM"
}
transform T_rowContribs {
op: MAP
elements: row : LIST<RECORD:RateRow>
context: sumInsured : NUMBER
binding: block "ks-row-contribution" // a block per item: one NUMBER output
}
connect T_eligible.age -> T_eligibleCount.age
recordfield, recordfields
Fields out of a row
recordfield RF_loadCode {
in row : RECORD:RateRow
field: rowCode
out code
}
recordfields PR from primaryRate : RECORD:RateRow // one step per field: PR_rateValue, PR_rowCode, ...
A formula can read row.field directly; a recordfield is for wiring one field somewhere a formula can’t go, such as a lookup’s dynamic key or a block’s input.
aggregate
aggregate
aggregate A_ncd {
sign: SUBTRACT // or ADD
}
connect A_rowLoad -> A_ncd.currentTotal // the total so far
connect F_ncd -> A_ncd.contribution // this step's amount
connect A_ncd -> F_vat.runningTotal // newTotal, onward
Connections into an aggregate always name the port. One aggregate may feed only one next aggregate: a chain has a single order or it is refused (AMBIGUOUS_CHAIN_ORDER).
fetch
fetch: sugar, or wired by hand
fetch Fe_fxRate {
in quoteDate : DATE // parameters, substituted into the operation
out usdKes : NUMBER // the typed result
connection: "ks-http-conn"
operation: "ks-fx-op"
guard: D_windscreen.true
}
fetch Fe_classFactor {
in vehicleClass : STRING
out classFactor : NUMBER
}
connect L_dbConn -> Fe_classFactor.$connection
connect L_dbOp -> Fe_classFactor.$operation
block
block
block B_rate {
in key1 : STRING
in x : NUMBER
out rate : NUMBER // must match the block's own ports exactly
param tableCode = "motor-base-rate" // literal: NUMBER, STRING or BOOLEAN
binding: block "protected:table-rate:1d-band"
}
block B_period { // bound at run time instead
in amount : NUMBER
out adjustment : NUMBER
}
connect L_period -> B_period.$binding
output
output
output O_quote {
in premium : NUMBER // optional: ports are also made by connect
}
connect A_vat -> O_quote.premium
connect B_period.days -> O_quote.coverDays
NUMBER values are rounded to two places, half to even, at an output and nowhere else. Several outputs merge into one result map.
Wiring
Form
Means
connect A -> B
A’s only output into B, on a port named after A
connect A.port -> B.port
Explicit at both ends
connect A -> B.port
A’s only output into a named port
connect A.port -> B
A named output into a port named after A
connect L -> X.$binding · $connection · $operation
A lookup supplies the content a step runs
A step with several outputs needs A.port on the left (AMBIGUOUS_SOURCE_PORT otherwise).
An input port takes exactly one connection (MULTIPLY_WIRED_INPUT). To choose between values, compute the choice in a formula with if, or dispatch with a dynamic lookup.
binding: on a step and a hand-wired $binding are alternatives; using both is BINDING_ALREADY_WIRED_MANUALLY.
Formulas become versions and links
expression: alone saves the text under the step’s id as its formula key, so step ids carrying an inline expression must be unique across the whole document, blockdefs included.
binding: formula "k" with expression: saves the text under k.
binding: formula "k" alone refers to a formula already saved; a key nobody has saved is the warning UNKNOWN_FORMULA_KEY.
formuladef "k" saves under k, for any number of steps to bind.
Importing saves a new formula version only when the text changed, and drafts one link (a node binding) per authored key, pinning that version in the import’s scope. Re-importing an unchanged document creates nothing new.
Checks
Every diagnostic is {stage, rule, message, line, column, offendingToken, suggestion, severity}, in the formula language’s shape, so one editor component shows both. Only ERRORs make a document invalid.
Stage
Examples
LEX, PARSE
An unknown character; “Expected ’out’, ’in’, ’binding’, or ’expression’ inside formula body.”
The formula language’s own findings, moved to the line and column in your file
Warnings
TOO_MANY_TOP_LEVEL_NODES (over ~30), UNKNOWN_FORMULA_KEY, BLOCK_BINDING_UNRESOLVED (a block not published in this scope yet), BLOCK_BINDING_UNBOUND (a block needs formula links your scope doesn’t have)
Blocks nest at most six levels and never recursively. A call’s params are checked against a blockdef in the same document, or against the published block when the request carries a scope.
Endpoints
Call
Does
POST /api/workflow/dsl/validate
{source, scope?, asOfDate?} → {valid, diagnostics}. Writes nothing; safe to call as the author types
POST /api/workflow/dsl/compile
As validate, plus the compiled graph and blocks
POST /api/workflow/dsl/simulate
{source, scope?, asOfDate?, entry?, inputs} → {valid, diagnostics, outputs, trace}. Runs unsaved text, its own formulas and blockdefs standing in for saved ones; entry {"kind": "block", "key": …} runs one blockdef alone
POST /api/workflow/dsl/import
{source, scope, effectiveFrom, effectiveTo?, expectedWorkflowVersion?, expectedBlockVersions?, reason} → {definitions, formulas, bindings, warnings}. All or nothing; everything saved is a DRAFT
GET /api/workflow/dsl/export/{workflow|block}/{id}
The text: STORED (as authored, comments kept) or CANONICAL (printed from the graph)
GET /api/workflow/dsl/completions?…scope…&asOfDate=
What an editor can’t read from the text: published blocks with their ports, params and required links; workflow, formula and lookup keys; the built-in functions
GET /api/workflow/dsl/formulas/{bindingKey}?…scope…
The formula text a link runs in a scope, and whether newer text is saved but not yet linked (stale)
Values are {"type", "value"}; a list is {"type": "LIST", "elementType", "elements": [...]}; a record carries its schema: {"type": "RECORD", "recordType": {name, fields}, "fields": {...}}. A malformed value is a 400 naming the input.
Outputs and trace carry NUMBERs at full precision; rounding happens inside the engine at output steps.
Import over an existing key needs its current version (expectedWorkflowVersion, expectedBlockVersions), else 409; a new version needs a later effectiveFrom.
What doesn’t round-trip: comments (except in STORED export), canvas positions and labels, the ids of generated binding steps, and the order of a step’s input ports.
Try the language in Sandbox
A complete BimaStack for developers, wiped every night.