Flow QA Checklist
Static validation of a built flow. Treat a flow as a program — reachable, well-typed, and side-effect-aware. This skill produces a pass/fail report with actionable findings.
All checks use the Flow Assistant's own read tools: get_flow_overview, get_node, get_edges_from, get_edges_to, list_node_types. Do not reference MCP tools or external CLI tools — they are not available in this context.
Companion: flow-ddd skill if you need to explain variable-namespace findings.
When to Use
Use this skill when:
- A flow has just been built and needs pre-submit validation.
- Before declaring a flow "ready" for the user.
- After any structural change (add/delete node or edge), before handing back.
- The user asks "is it ready?", "lint this flow", "review the flow", "QA it".
Do NOT use when:
- The flow is mid-build — run QA once at the end, not after every partial change.
- The flow is actively broken with a known, specific bug the user has already described — go straight at that bug instead of running the full checklist, then QA once it's fixed.
QA Output — Pass/Fail Report
Produce a report with these sections:
## QA Report — <Flow Name> (#<flow_id>)
### Result
PASS | FAIL
### Structural
[✓|✗] __start__ connects to downstream
[✓|✗] No dangling nodes (every non-end node has outgoing route)
[✓|✗] Trigger nodes have no input edges
[✓|✗] Every edge endpoint resolves to a live node in this graph
[✓|✗] decision_table / classification_decision_table route targets resolve to real nodes
### Data Flow
[✓|✗] Every `input_map` path is declared in start variables or written upstream
[✓|✗] Every declared start variable is actually read by something (or marked intentionally seeded)
[✓|✗] No two nodes write to the same `output_variable_path`
[✓|✗] End node `output_map` references paths that get written
### Per-Node Correctness
[✓|✗] python / webhook_trigger nodes have non-empty `libraries` if code imports non-stdlib
[✓|✗] python / webhook_trigger nodes define `def main(...)`
[✓|✗] decision_table rules read as valid Python boolean expressions (spot-check)
[✓|✗] classification_decision_table rules have sane `field_expressions` / routing (spot-check)
### Findings
1. <finding 1 — severity, location, suggested fix>
2. ...
### Recommended next step
<build is clean | fix specific patch | needs clarification from user>
Severity:
- blocker — flow will fail at runtime. Must fix.
- warning — not a guaranteed failure but likely a bug. Investigate.
- nit — code smell, naming, unused variable. Fix at leisure.
The Checks — What and How
Run each check explicitly. Do not skip the ones that "look obviously fine" — the point is evidence, not intuition.
1. Structural reachability
Tools: get_flow_overview, get_edges_from, get_edges_to, get_node.
__start__has at least one outgoing edge (verify withget_edges_from).- Every non-trigger, non-end node is reachable from
__start__. A node referenced bydecision_table/classification_decision_tablerouting counts as reachable too. conditional_edgerouting is invisible toget_edges_from/get_edges_to—ConditionalEdgerows live in a separate table fromEdgeand are not returned by either tool. A node with zero outgoingEdgerows may still be routed to from aconditional_edgenode whosesource_node_idpoints at it. Before flagging such a node as a dead end, check whether anyconditional_edgenode has that node as its source, then read that conditional_edge'spython_code_summary.codeto recover the string targets it can return. If you can't resolve the targets this way, report the reachability check as unverifiable for that node rather than as a blocker — don't invent a pass result, and don't invent a fail result either.- Every execution path reaches the end node (or a decision-table error branch that reaches end).
- Trigger nodes (
webhook_trigger,telegram_trigger,schedule_trigger) have zero incoming edges (verify withget_edges_to). - When a trigger exists,
__start__is also wired into the first real node (dual entry). - Every edge's source and target id resolves to a node that actually exists in this graph —
get_edges_from/get_edges_toreturning an id thatget_flow_overview/get_nodecan't find is stale wiring left over from a deleted node. decision_tableroutes: every rule'sroutes_to_node_id, plus the node-leveldefault_next_node_idandnext_error_node_id, resolve to a real node (verify by reading the node config viaget_node).classification_decision_tableroutes: every rule'sroutes_to_node_id, plus the node-leveldefault_next_node_idandnext_error_node_id, resolve to a real node.
If any of these fail, the fix is almost always a missing edge or stale node config.
2. Data-flow continuity
Tools: get_flow_overview (node inventory), get_node (read each node's input_map, output_variable_path, and decision-table rule detail).
Build two tables:
Writers table. For every output_variable_path across all nodes: which node writes it.
- A path with two writers is a blocker unless the design is explicitly override-last-wins (document the intent).
- A path with zero writers is a blocker if anyone reads it.
Readers table. For every input_map value across all nodes: which node reads it.
- Every path must appear either (a) in the start node's initial
variablesor (b) in the writers table with an execution order that precedes the reader. - A path read but never written is a blocker — even though the runtime won't crash on it (a missing path resolves quietly to
None, or to a|defaultfallback if theinput_mapvalue uses pipe-default syntax), it's still a real bug worth flagging.
For end node output_map: every value path must appear in the writers table or start variables. If a value is referenced only via output_map, the runtime silently resolves it to the string "not found" — warning-level, not blocker.
3. Per-node correctness
For each node type, verify the per-type invariants.
- start:
variablesis a non-empty dict; every path any downstreaminput_mapreferences is declared (even asnull). - end:
output_mapnon-empty; every referenced path is written upstream (or acknowledged as default"not found"). - python: code contains
def main(...); every import satisfies one of (a) stdlib, (b) appears inlibraries;input_mapkeys map to kwargs ofmainor are explicit paths;output_variable_pathset if output is used downstream. - webhook_trigger:
python_code_summary.codecontainsdef main(trigger_payload=None);librariespresent; bad-input branches return{"error": ..., "status": 400}. - telegram_trigger / schedule_trigger: has zero incoming edges (see check 1); downstream node consumes whatever payload shape the trigger produces.
- task: has an
agent_definitionset (atasknode with no agent will fail at runtime);instructionsnon-empty;output_schema, if set, is valid JSON schema;output_variable_pathset if the result is consumed downstream. - agent: has an
agent_definitionset; its internal sub-tasks each have a uniquename, a contiguousorder, and anycontext_task_idsreference an earlier sub-task byid(forward references are invalid);output_variable_pathset if the result is consumed downstream. - conditional_edge:
python_code_summary.codereturns a string (assert in code), and that string is always a live node's name. - decision_table: node-level
default_next_node_idandnext_error_node_idare both set (unset is a blocker — an unrouted default/error case falls back to END silently); every rule has a uniquerule_name;rule_typeissimpleorcomplex;simplerules have non-emptyconditions[]whoseexpressionreads as a Python boolean expression;complexrules have a non-null top-levelexpressionjoining the conditions; every rule'sroutes_to_node_idresolves to a real node (null is only valid if the rule is deliberately left unwired — flag it if so). - classification_decision_table: node-level
default_next_node_idandnext_error_node_idare both set; each rule'sroute_code(if used) is unique within the node;field_expressionsmeaningfully describes what the LLM should extract or classify;continue_to_next_rulecorrectly reflects whether a rule is meant to fall through to the next one;prompt_id, if set, references a real prompt;routes_to_node_idresolves to a real node. - subgraph: referenced subgraph exists; circular references absent.
- file_extractor, audio_transcription: input is a path or file ref the runtime can consume;
output_variable_pathset. - crew (deprecated/legacy): treat as a migration candidate rather than an error — flag it as a warning/nit suggesting a move to
agent/task, not a blocker.
4. Error handling coverage
- Every trigger node has a validation step shortly after it (webhook typically → python validator that returns
{"error", "status": 400}on bad input, routed to end viadecision_table). - Every
decision_table/classification_decision_tablehasnext_error_node_idset (blocker if unset — the runtime falls back to END silently). - Every path that can raise (external HTTP calls, file parsing, LLM calls) either has an explicit try/except in the node code or sits upstream of a decision-table node that can route errors.
5. Side-effect placement
Side effects (external API writes, file writes, emails, messages) belong in clearly named nodes, not buried inside a conditional_edge or a decision-table manipulation. A reader of the graph should be able to see where side effects happen just from node names and types.
Flag as a warning any:
- Decision-table
manipulationthat callsrequests/ sends messages / writes files. conditional_edgecode with side effects (it should only compute a target string).pythonnode that both transforms data AND sends outbound messages — split responsibilities.
6. Naming and domain hygiene
Tie back to flow-ddd:
variablesis shaped as domain dicts, not a flat key bag.- Node names describe responsibilities in business language ("Fetch Weather", not "Node 1").
- Decision-table rule names are short and distinctive — renaming them later can break canvas wiring that references them by name.
7. Runtime smoke test
Skip runtime smoke — Flow Assistant cannot run sessions. Report all findings as static only and note this limitation in the report.
Working the Checklist — Execution Order
Do the checks in order. Stop and write up findings if a blocker surfaces early; a downstream check may depend on an earlier check being clean.
get_flow_overview— node inventory (types, ids, names) and edge count.- For each node:
get_node(node_id)— full config, code, libraries, maps, decision-table rules. get_edges_from/get_edges_to— wiring per node; build the full edge list.- Cross-reference: build the writers / readers tables from the node inventory.
- Per-node correctness pass (uses decision-table detail from step 2).
- Error handling and side-effect review.
- Runtime smoke: not available — report findings as static only.
Do NOT patch in the middle of QA. Collect findings, then report them.
Sample Finding — Good Format
Finding 2 — blocker
Node: Fetch Weather (python)
Issue: input_map has "city": "variables.request.city", but start variables declare
"variables.request.message" instead. No upstream writer for variables.request.city.
Evidence: get_node(start_id) -> start.variables = {"request": {"message": null, "units": "celsius"}}
Fix: Either rename start var to `city`, or update the webhook validator to write
`variables.request.city`, or update Fetch Weather's input_map to read .message.
A bad finding:
The flow looks a bit off around the webhook.
Be specific. Every finding must cite the node, the symptom, the evidence from the tool output, and a concrete fix.
Output Format
Format the report as the message field (Markdown). Include:
- An
openFlowbutton. - An
openNodebutton for the first blocker finding (if any), targeting that node. - Prompt chips: "Show me the details of finding 1", "Walk me through the data-flow issues".
Example:
{
"message": "## QA Report — Weather Report Demo (#55)\n\n**Result:** FAIL (1 blocker, 2 warnings)\n...",
"action_message": [
{"type": "button", "text": "Open flow", "action": "openFlow", "params": {"flowId": "55"}},
{"type": "button", "text": "Open Fetch Weather", "action": "openNode", "params": {"flowId": "55", "nodeId": "<uuid>"}},
{"type": "prompt", "text": "Show me the details of finding 1"},
{"type": "prompt", "text": "Walk me through the data-flow issues"}
]
}
Pass Criteria
A flow passes QA only when:
- Every blocker check is green.
- No unresolved
output_mappath that would silently resolve to"not found". - No stale edge endpoints (every edge resolves to live nodes on both ends).
Anything less is a FAIL — report the blockers first, warnings next, nits last.
Do Not
- Do not patch during QA. Report findings and let the user apply fixes.
- Do not skip checks that "obviously pass" — the point is evidence.
- Do not invent a pass result. If you couldn't run a check, say so in the report.
- Do not reference MCP tools or external CLI commands — they do not exist in this context.