Flow simulation
Model request paths with conditional relationships, per-node routing, quorum requirements and named scenario assertions. All flow metadata persists on canvas elements and survives compile/decompile.
Model request paths with conditional relationships, per-node routing, quorum requirements and named scenario assertions. All flow metadata persists on canvas elements and survives compile/decompile.
Relationships and nodes
Relationships accept when, a boolean expression, or otherwise: true, a fallback.
An edge cannot carry both. Each source has at most one fallback.
Edges without either field are unconditional. A fallback is used only when there
are no matched conditional or unconditional candidates.
Any node, including C4 boundary children and nested cloud descendants, may carry:
flow:
behavior: switch # switch, fanout or quorum; omitted means fanout
lookup: { table: tenants, key: input.tenant, as: tenant }
The lookup table must exist. Its key is an expression, and as is an identifier
matching [A-Za-z_][A-Za-z0-9_]*. Lookup runs before outgoing conditions and binds
the table row (or null for a missing row) in that branch's scope. Flow and lookup
objects reject unknown fields.
A quorum node uses flow: { behavior: quorum, need: 2 }. need must be an
integer >= 1, is required only for quorum, and cannot exceed the number of
outgoing relationships. Quorum fans out over the matched candidates. After
traversal, a child branch succeeds if its subtree has at least one reached
terminal and no failed or error terminals. Only taken branches count.
Nested quorums are evaluated from the leaves upward. Each visited quorum step
appears in result.quorums with stepId, nodeKey, need, branches, succeeded, met.
An unmet quorum adds a failed terminal on that step with a message such as
quorum not met at shardA: 1 of 2 needed. quorums is always present on
successful results (empty without quorum visits); text output lists it after Terminals.
Tables and scenarios
Top-level tables maps table names to row-key maps containing any JSON values.
Top-level scenarios is an array with unique, non-empty name, optional start,
input (a record, default {}), and faults (known node keys, default []).
Start is checked during evaluation. Without an explicit start, the graph must
have exactly one root: a node with outgoing edges and no incoming edges.
Assertions
Top-level assertions is a list with unique, non-empty name and optional
scenarios (default all named scenarios). Each assertion has exactly one predicate:
reaches: X: X is visited.neverReaches: X: X is not visited.alwaysPassesThrough: X: every reached terminal has X on its ancestry, including itself. With no reached terminals this passes.noErrors: true: no error terminals and every quorum is met. Ordinary failed or dropped terminals alone do not fail this predicate.
Node keys and scenario names must exist, otherwise compilation fails.
checkFlowAssertions(graph) returns { name, results: [{ scenario, pass, message }], pass }[].
Each scenario that fails evaluation (ok: false) fails every assertion over it
with the evaluation error. An assertion passes when all selected scenarios pass.
formatAssertionResults(results) renders lines such as PASS Writes replicate
or FAIL Acme never touches shard B (Globex read: reached shardB).
The flow panel displays assertions above the scenario picker, quorum summaries
with results, and failed quorum nodes using the failed outcome color.
The MCP simulate_flow tool accepts checkAssertions: true to check stored
assertions without scenario/input selectors and returns { text, assertions, pass }.
Expression grammar
Precedence, from low to high:
||&&== !=< <= > >= in- Unary
! - Parentheses, literals, identifiers and member/index access
Literals are single- or double-quoted strings (escape the matching quote or
backslash with a backslash), integers/decimals with optional leading minus,
true, false, and null. Access input, tables, and lookup bindings
through paths such as tenant.shard, tables["tenants"][input.tenant].
Missing names, fields and indices yield null. Equality compares JSON values
deeply. Ordering requires two numbers or two strings. in checks deep array
membership, object key presence, or string substrings (with a string left side).
Logical operators require booleans; && and || short-circuit.
Expressions are limited to 500 characters and nesting depth 64. Parse failures
include a one-based column; evaluation errors are returned, never thrown.
Evaluation rules and API
import { buildFlowGraph, evaluateFlow, formatFlowResult } from '@archdocs/dsl'
const graph = buildFlowGraph(elements)
const result = evaluateFlow(graph, 'Acme write', { maxSteps: 200 })
console.log(formatFlowResult(result))
// Ad hoc scenarios also work:
evaluateFlow(graph, { start: 'client', input: { tenant: 'acme', op: 'write' } })
The graph comes from live elements, including canvas-drawn diagrams without DSL keys (element ids become keys). Bound labels, C4 parts and the model carrier do not become nodes. Arrow bindings resolve endpoints; unbound arrows become warnings. Graph validation errors also appear as warnings in successful runs.
Traversal is breadth first, preserving element/edge order. Each visit records a stable step id, depth tick, parent step, incoming edge and branch-local lookup bindings. Fanout follows all candidates. Switch requires exactly one candidate: zero is a no-route error and multiple are an ambiguous-switch error.
Faulted nodes terminate as failed before lookup/routing. Nodes with no outgoing
edges terminate as reached; fanout with no candidates terminates as dropped.
Condition type/evaluation errors and ancestry cycles terminate the affected branch
as error. Exceeding maxSteps (default 200) stops the entire run with
ok: false, as do unknown scenarios/starts and missing or ambiguous roots.
An ok: true result may therefore contain failed or error terminals; inspect
terminals to assess the scenario. Quorum summaries include branch success counts; timing is not modeled.
GigglesDB worked example
Acme writes reach Shard A and its followers. Globex reads stop at Shard B with
dropped and no condition matched at shardB, because replication is write-only.
Unknown tenants take the fallback to reject; a fault at Shard B terminates there.
apiVersion: archdocs/v1
title: GigglesDB tenant routing
tables:
tenants:
acme: { shard: a }
globex: { shard: b }
scenarios:
- { name: Acme write, input: { tenant: acme, op: write } }
- { name: Globex read, input: { tenant: globex, op: read } }
- { name: Unknown tenant, input: { tenant: initech, op: read } }
- { name: Globex write shard B down, input: { tenant: globex, op: write }, faults: [shardB] }
assertions:
- { name: Acme never touches shard B, scenarios: [Acme write], neverReaches: shardB }
- { name: Writes replicate, scenarios: [Acme write], reaches: shardAFollower1 }
- { name: Everything goes through the router, alwaysPassesThrough: router }
- { name: No scenario errors, noErrors: true }
nodes:
- { id: client, type: c4-node, kind: person, label: Tenant client }
- { id: gateway, type: c4-node, kind: service, label: SQL gateway }
- id: router
type: c4-node
kind: component
label: Tenant router
flow: { behavior: switch, lookup: { table: tenants, key: input.tenant, as: tenant } }
- {
id: shardA,
type: c4-node,
kind: database,
label: Shard A leader,
flow: { behavior: quorum, need: 2 },
}
- { id: shardAFollower1, type: c4-node, kind: database, label: Shard A follower 1 }
- { id: shardAFollower2, type: c4-node, kind: database, label: Shard A follower 2 }
- { id: shardB, type: c4-node, kind: database, label: Shard B leader }
- { id: shardBFollowers, type: c4-node, kind: database, label: Shard B followers }
- { id: reject, type: c4-node, kind: component, label: Reject unknown tenant }
relationships:
- { from: client, to: gateway, label: SQL request }
- { from: gateway, to: router }
- { from: router, to: shardA, when: "tenant.shard == 'a'" }
- { from: router, to: shardB, when: "tenant.shard == 'b'" }
- { from: router, to: reject, otherwise: true }
- { from: shardA, to: shardAFollower1, when: "input.op == 'write'" }
- { from: shardA, to: shardAFollower2, when: "input.op == 'write'" }
- { from: shardB, to: shardBFollowers, when: "input.op == 'write'" }