ArchDocs
Start free

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:

  1. ||
  2. &&
  3. == !=
  4. < <= > >= in
  5. Unary !
  6. 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'" }