# Forms v2: flow documents

The automation model: a directed graph stored as JSON on the flow's handle,
executed asynchronously after each public submission. Sources:
`internal/render/formactions/flow.go`, `flow_validator.go`, `flow_walker.go`,
`actions.go`, `executor.go`; GraphQL surface in
`internal/graphql/forms_schema.go`.

---

## The flow document

Exact wire shape (`flow.go` structs — key names are the JSON contract, do not
rename):

```json
{
  "version": 2,
  "nodes": [
    {
      "id": "n1",
      "type": "entry",
      "data": {}
    },
    {
      "id": "n2",
      "type": "action",
      "data": {
        "action_type": "send_email",
        "config": { "to": ["sales@example.com"] },
        "on_error": "continue"
      }
    },
    {
      "id": "n3",
      "type": "condition",
      "data": {
        "predicate": {
          "combinator": "and",
          "clauses": [
            { "field": "form.budget", "operator": "gte", "value": "1000" }
          ]
        }
      }
    },
    {
      "id": "n4",
      "type": "terminal",
      "data": {}
    }
  ],
  "edges": [
    { "from": "n1", "to": "n2" },
    { "from": "n2", "to": "n3" },
    { "from": "n3", "to": "n4", "branch": "then" },
    { "from": "n3", "to": "n4", "branch": "else" }
  ]
}
```

| Key | Type | Notes |
|---|---|---|
| `version` | int | **must be exactly `2`** (`"unsupported flow version %d (want 2)"`) |
| `nodes[].id` | string | unique, non-empty, caller-chosen |
| `nodes[].type` | string | one of `entry` \| `action` \| `condition` \| `terminal` (verified — these exact strings, part of the contract with the Vue Flow editor) |
| `nodes[].data.action_type` | string | action nodes only — one of the action types below |
| `nodes[].data.config` | object | action nodes only — free-form per-type config |
| `nodes[].data.on_error` | string | `"continue"` (default) or `"stop"` — what happens when the action errors |
| `nodes[].data.predicate` | object | condition nodes only — `{combinator, clauses[]}` |
| `edges[].from` / `edges[].to` | string | node ids |
| `edges[].branch` | string | `"then"` \| `"else"` — **only on edges leaving a condition node**; omitted (`""`) on entry/action/terminal edges |

Predicate details:

- `combinator`: `"and"` or `"or"` (there is no NOT combinator — negation is
  the `ne` operator).
- Each clause: `{field, operator, value}` — all strings. `field` is a dotted
  path into the template context, e.g. `form.email`.
- Operators (exactly these TEN): `eq`, `ne`, `gt`, `gte`, `lt`, `lte`,
  `contains`, `matches` (Go regexp), plus `bcrypt_matches` and `token_valid`
  (member auth — value is Liquid-rendered for these two ONLY, literally
  compared for the rest). Unknown operator fails validation. Unknown operator
  at eval time fails closed (condition not satisfied). `gt/gte/lt/lte`
  compare numerically (non-numeric → no match).
- The response-affecting action types `set_cookie`, `redirect`,
  `respond_json`, `render` make a flow run SYNCHRONOUSLY (directives shape
  the HTTP answer). Full per-type configs, the Liquid context table, route
  bindings, response caching, and export/import live in
  [flows-guide.md](flows-guide.md) — including §2.1 (assembling JSON
  `body_template`s with `| json`, and the `blank`/`strip` traps of the
  flow engine); member login/logout/reset flows in
  [member-auth.md](member-auth.md).

---

## Validation rules

`ValidateFlow` (`flow_validator.go:13-95`) rejects, in order:

1. `version` != 2.
2. Not **exactly one** `entry` node (zero or several).
3. Empty node id; duplicate node ids.
4. Per-node data: unknown `type`; `action` node without `action_type`;
   `condition` node without `predicate`; predicate combinator not `and`/`or`;
   clause with empty `field` or unknown `operator`.
5. Edge endpoints referencing missing node ids (`from` or `to`).
6. Outgoing-edge arity:
   - `entry` / `action` / `terminal`: **at most one** outgoing edge;
   - `condition`: every outgoing edge must carry a `branch` label (an
     unbranched edge out of a condition is invalid), **exactly one `then`**
     edge, **at most one `else`** edge.
   - A missing outgoing edge is allowed everywhere (it means "end here" —
     an implicit terminal); note a terminal node with no outgoing edge is the
     normal shape.
7. **Cycles**: DFS from the entry revisiting a node on the current path →
   `"cycle detected at node <id>"`. (The walker additionally carries a
   hard 1000-visit guard in case a cycle slips past.)

There is **no requirement for a terminal node** — a flow may simply run out of
edges. There is no reachability requirement either: unreachable nodes are
legal (they just never execute). The GraphQL resolver maps the validator's
multi-line message onto `FORM_FLOW_INVALID` (generic), `FORM_FLOW_CYCLE`
(message contains "cycle"), `FORM_FLOW_SHAPE` ("entry"/"duplicate"/"missing"),
`FORM_FLOW_CONDITION_BAD` ("predicate"/"combinator") — see
`internal/graphql/forms_schema.go:704-722`.

---

## Action types

`dispatch` switch (`actions.go:41-57`) — the complete list:

### `send_email`

Config: `to` (string or array of strings, required), `cc` (same), `subject`,
`body_template`. Every string value is rendered as a **Liquid template**
against the submission context. The node carries its own `smtp` block (see
below); without a complete one the visit records `status: "error"` with
`smtp: node SMTP not configured`.

Two things the flow config cannot discover for you:

- `to` must be a concrete address — there is no API to look up the tenant
  owner's email. Ask the user which address notifications should go to.
- SMTP lives ON THE NODE: every send_email config carries an `smtp` object
  (`host`, `port`, optional `username`/`password`, `from`, `from_name`) —
  ask the user for their server's credentials and write them straight into
  the node. Without a complete `smtp` block the visit records
  `status: "error"` with `smtp: node SMTP not configured`.

```json
{
  "action_type": "send_email",
  "config": {
    "to": ["sales@example.com", "{{form.email}}"],
    "subject": "New request from {{form.name}}",
    "body_template": "Name: {{form.name}}\nMessage: {{form.message}}"
  }
}
```

### `http_post`

Config: `url` (required), `body_template`, `headers` (object of
string→string, values templated), `timeout_sec` (default 10), `result_key`
(optional), `secret_params` (optional object of name→string). Posts
`Content-Type: application/json` with the rendered body (override the header
for form-encoded APIs). Response >= 400 is an error.

- `result_key: "captcha"` stores the PARSED JSON response under
  `results.captcha`, so a later condition node can branch on it —
  `field: "results.captcha.success", operator: "eq", value: "true"`. Without
  `result_key` the response is discarded.
- `secret_params: {"secret": "secret://turnstile_secret"}` appends
  url-encoded `secret=<value>` to the body AFTER the rendered template. The
  value SHOULD be a whole-string `secret://<name>` reference — only then is
  it resolved from the tenant's Secrets before dispatch and enforced
  fail-closed (`SECRET_NOT_FOUND` on an unknown name). A literal string is
  sent as-is — never put a real key there. Empty body + secret_params
  produces exactly `name=value` (no leading `&`).

```json
{
  "action_type": "http_post",
  "config": {
    "url": "https://hooks.example.com/lead",
    "body_template": "{\"name\":\"{{form.name}}\",\"email\":\"{{form.email}}\"}",
    "headers": { "X-Source": "dynapi-form" },
    "timeout_sec": 5
  }
}
```

Captcha guard recipe (per-flow; same shape for all three providers):

```json
[
  { "id": "c1", "type": "condition",
    "data": { "predicate": { "combinator": "and", "clauses":
      [{ "field": "form.captcha_token", "operator": "ne", "value": "" }] } } },
  { "id": "v1", "type": "action",
    "data": { "action_type": "http_post", "config": {
      "url": "https://challenges.cloudflare.com/turnstile/v0/siteverify",
      "headers": { "Content-Type": "application/x-www-form-urlencoded" },
      "body_template": "response={{ form.captcha_token }}",
      "secret_params": { "secret": "secret://turnstile_secret" },
      "result_key": "captcha", "timeout_sec": 10 } } },
  { "id": "c2", "type": "condition",
    "data": { "predicate": { "combinator": "and", "clauses":
      [{ "field": "results.captcha.success", "operator": "eq", "value": "true" }] } } }
]
```

`c1` else and `c2` else lead to a `respond_json` node
(`{"error":"CAPTCHA_FAILED"}`, status 403). Do NOT set `on_error: "stop"` on
the verify node: a siteverify outage would then halt the walk before any
directive — the visitor gets `201` and a persisted submission (fail-OPEN).
With the default `continue` an errored fetch leaves `results.captcha`
unresolved, `c2` fails closed, and the deny node answers 403. This per-flow
guard and the platform-wide `RENDER_TURNSTILE_SECRET` gate are mutually
exclusive — siteverify tokens are single-use.

Provider matrix (everything else in the recipe stays the same):

| Provider | `url` | body token param | secret name | verdict condition |
|---|---|---|---|---|
| Cloudflare Turnstile | `https://challenges.cloudflare.com/turnstile/v0/siteverify` | `response` | `turnstile_secret` | `results.captcha.success eq "true"` |
| Yandex SmartCaptcha | `https://smartcaptcha.cloud.yandex.ru/validate` | `token` | `yandex_captcha_secret` | `results.captcha.status eq "ok"` |
| Google reCAPTCHA | `https://www.google.com/recaptcha/api/siteverify` | `response` | `recaptcha_secret` | `results.captcha.success eq "true"` |

The widget's hidden input lands in the form under the provider's own name
(`cf-turnstile-response` / `smart-token` / `g-recaptcha-response`); the
backend aliases ANY of them into the RESERVED `form.captcha_token` (always
stripped from stored submissions) — address the token as
`form.captcha_token` everywhere. The editor's «Форма с капчей» preset
(provider picked in its wizard) and the creation dialog's preset select
seed exactly this graph. Gate note: with the platform-wide
`RENDER_TURNSTILE_SECRET` gate ON, every POST must carry a valid Turnstile
token — a Turnstile guard and the gate are mutually exclusive; a
SmartCaptcha/reCAPTCHA guard needs the form to ALSO carry the platform
Turnstile widget while the gate is on (with the gate off, the provider
widget alone is enough).

### `notify_telegram`

Config: `webhook_url`, `message_template`, `chat_id` (optional). Sends a JSON
body: other URLs receive the Slack-style `{"text": "<message>"}` payload; an
`api.telegram.org` Bot API URL additionally REQUIRES `chat_id` (the action
errors without it) and receives `{"chat_id": ..., "text": ...}`. There is no
bot-token field anywhere — tokens live inside the webhook URL.

```json
{
  "action_type": "notify_telegram",
  "config": {
    "webhook_url": "https://api.telegram.org/bot<TOKEN>/sendMessage",
    "chat_id": "123456789",
    "message_template": "New contact: {{form.name}} <{{form.email}}>"
  }
}
```

### `graphql`

Runs an arbitrary GraphQL document against the tenant's own CMS under the
tenant's **service-account key**. Config: `query` (required), `variables`
(object; every **string leaf** is Liquid-rendered, non-strings pass through).
A non-2xx response **or a non-empty `errors[]` array** is an action error
(partial GraphQL failures surface as `status: "error"`, never silent success).
Use this instead of hand-rolled entity shortcuts — e.g. persist the lead as an
entity:

```json
{
  "action_type": "graphql",
  "config": {
    "query": "mutation($input: LeadInput!) { createLead(input: $input) { id } }",
    "variables": {
      "input": { "name": "{{form.name}}", "email": "{{form.email}}" }
    }
  }
}
```

(`Lead` must exist as an entity definition first; the flow does not create it.)

### `condition` (as an ACTION type — distinct from condition NODES)

Config: `{field, operator, value}` — a single clause; **non-match stops the
walk** (the subsequent edge is not followed). Prefer condition **nodes** with
`predicate`/branch edges for anything non-trivial.

---

## Execution model

When a public form POST is persisted (`internal/render/formpost.go`), one of
two execution paths runs. A flow whose nodes include `set_cookie`,
`redirect`, `respond_json`, or `render` runs SYNCHRONOUSLY and its
directives shape the HTTP response (member login/logout flows work this
way, with a 30s cap). A flow without response directives runs as a
**detached goroutine** with a 30s hard timeout — the HTTP 201/303 response
never waits for or reflects it.

Template context available to every Liquid render (config strings, subjects,
bodies, GraphQL variable leaves). The request body is **namespaced by its
transport** — a browser form populates `form.*`, a JSON integrator `json.*`,
an XML client `xml.*`:

```
form       → fields of a form-encoded/multipart POST. GET-binding flows
             also mirror the query string here. Captcha: with the platform
             Turnstile gate off (or for non-Turnstile widgets in any gate
             state), the widget field (cf-turnstile-response /
             smart-token / g-recaptcha-response) survives AND a
             provider-agnostic alias `captcha_token` is added — same value
             as the first non-empty SURVIVING widget field (gate-ON the
             Turnstile field is deleted before aliasing, so a foreign token
             wins the alias) — address the token as
             `form.captcha_token`; the alias name is RESERVED (stripped from
             stored submissions). With the gate ON, EVERY POST must carry a
             valid Turnstile token (the gate rejects others with 403
             CAPTCHA_FAILED) and the Turnstile token is consumed before the
             flow — so a Turnstile guard cannot run gate-ON; once the gate
             passed, other providers' fields still reach the flow aliased.
             honeypots/control fields are always stripped.
json       → the parsed body of an application/json POST (nested objects nest)
xml        → the parsed body of an XML POST (application/xml, text/xml, or any
             +xml type), keyed by the root element: <order><n>5</n></order>
             → {{ xml.order.n }}; repeated siblings become arrays, attributes
             ride as @name. DOCTYPE/ENTITY declarations are rejected.
query      → URL query params (GET-binding flows)
results    → accumulated GraphQL result_key outputs ({{ results.<key>.… }})
member     → signed-in member identity when the request carries a session
params     → the matched route's :path segments (binding flows)
headers    → the request's headers (binding flows)
```

Unsupported request formats (e.g. `text/plain`) are rejected with **415
UNSUPPORTED_MEDIA_TYPE** instead of silently persisting an empty submission.

Walk (`flow_walker.go:32-86`), starting after the entry node:

1. **action** node → dispatch by `action_type`; record a visit
   (`success` or `error` + message + duration). If the visit errored and the
   node's `on_error` is `"stop"`, the walk ends immediately. `on_error:
   "continue"` (the default when omitted) logs the error visit and follows the
   outgoing edge anyway.
2. **condition** node → evaluate `predicate` against the template context;
   matched → follow the `then` edge, else → follow the `else` edge (no `else`
   edge → walk ends).
3. **terminal** node (or a node with no outgoing edge) → end.
4. Safety guard: the walk stops after 1000 visits.

Only **action** visits are recorded to the audit trail (`form_action_runs`
via the CMS internal API); entry/condition/terminal nodes are control nodes
and leave no rows. Query them with
`flowRunsForFlow(slug: "<route>:<slug>")` or
`flowRuns(submission_id: "<id>")` — fields `action_type`, `status`
(`success`|`error`), `error_message`, `duration_ms`, `started_at`.

---

## Minimal working example: contact form

The complete flow (entry → send_email → terminal) as copy-pasteable JSON:

```json
{
  "version": 2,
  "nodes": [
    { "id": "entry", "type": "entry" },
    {
      "id": "notify",
      "type": "action",
      "data": {
        "action_type": "send_email",
        "config": {
          "to": ["sales@example.com"],
          "subject": "New contact request",
          "body_template": "Name: {{form.name}}\nEmail: {{form.email}}\n\n{{form.message}}"
        },
        "on_error": "continue"
      }
    },
    { "id": "done", "type": "terminal" }
  ],
  "edges": [
    { "from": "entry", "to": "notify" },
    { "from": "notify", "to": "done" }
  ]
}
```

Save it with `saveFlowDoc` (the `flow` argument is the JSON document
**as a string** — escape the quotes in your GraphQL literal, or send it via a
`$flow` variable):

```graphql
mutation SaveFlow($flow: String!) {
  saveFlowDoc(
    slug: "contact"
    flow: $flow
  ) {
    slug
  }
}
```

Prerequisites for the example to fire end-to-end:

1. A flow exists: `createFlow(slug: "contact", route_path: "/contact")`
   — `slug` is the flow's TENANT-UNIQUE name (the pre-P6
   `route_path + ":" + slug` composite is gone; one identifier system).
   The `slug` argument is optional: omitted or empty normalizes to
   `"default"`.
2. The page at `/contact` is published and its `<form method="post">` posts
   to the same path — the POST reaches the flow through the route's POST
   binding (see flows-guide.md §6). Keep curl test bodies clean — send the
   body file with `--data-binary @file`, without a trailing newline.
3. The send_email node carries its own complete `smtp` block (the platform
   ships no mail and there is no tenant-level SMTP setting — see Action
   types); otherwise the send_email visit records `status: "error"` and, with
   `on_error: "continue"`, the submission still succeeds silently.
4. After a test submission, verify with
   `flowRunsForFlow(slug: "contact")` → one `send_email`
   visit with `status: "success"`.

A variant with a condition (skip the email for tiny budgets, still record):

```json
{
  "version": 2,
  "nodes": [
    { "id": "entry", "type": "entry" },
    {
      "id": "big_lead",
      "type": "condition",
      "data": {
        "predicate": {
          "combinator": "and",
          "clauses": [{ "field": "form.budget", "operator": "gte", "value": "1000" }]
        }
      }
    },
    {
      "id": "email",
      "type": "action",
      "data": {
        "action_type": "send_email",
        "config": { "to": ["sales@example.com"], "subject": "Big lead", "body_template": "{{form.name}} / {{form.budget}}" }
      }
    },
    {
      "id": "crm",
      "type": "action",
      "data": {
        "action_type": "graphql",
        "config": {
          "query": "mutation($input: LeadInput!) { createLead(input: $input) { id } }",
          "variables": { "input": { "name": "{{form.name}}", "budget": "{{form.budget}}" } }
        }
      }
    },
    { "id": "done", "type": "terminal" }
  ],
  "edges": [
    { "from": "entry", "to": "big_lead" },
    { "from": "big_lead", "to": "email", "branch": "then" },
    { "from": "big_lead", "to": "done", "branch": "else" },
    { "from": "email", "to": "crm" },
    { "from": "crm", "to": "done" }
  ]
}
```
