# Flows — the site engine

> A flow is the unit a site is built from. Every page, form, login, webhook
> receiver, or JSON endpoint on a tenant site is a flow document bound to one
> or more routes. This reference is the full contract: the document shape,
> every action's config, the Liquid context, route bindings, flow pages with
> response caching, and export/import. Member login flows are covered in
> [member-auth.md](member-auth.md).

## 1. The flow document

Stored per flow, saved and read as a **JSON string** through GraphQL. The
storage table keeps its pre-rename name `form_handles` — an internal detail
only; the API surface is `flowDoc`/`saveFlowDoc` and has NO `formHandles`
field:

```graphql
mutation SaveFlow($flow: String!) {
  saveFlowDoc(slug: "login", flow: $flow) { slug }
}
query { flowDoc(slug: "login") { slug flow } }
```

**The write is DRAFT-ONLY**: it lands in the handle's staging
copy, live keeps the previous document. Publish one flow with
`publishFlowDoc(slug) { slug published }` — instant cache
invalidation, no site-wide step. The preview host
(`preview-<sub>.<base>`) always executes the staged copy (falling back to
live when none) — GET and POST/flow mutations alike — so a fresh flow
(forms, login, registration) is fully testable before promotion. An empty
flow (nothing saved yet) serves a default placeholder page (HTTP 200) on
the preview host; LIVE answers 404 until the flow is published. Read the state
via `flows { flow_live flow_has_draft }` / `flowDoc(draft: true)
{ has_draft }`.

Shape (field names are part of the wire contract — do not rename):

```json
{
  "version": 2,
  "nodes": [
    { "id": "n_start", "type": "entry", "data": {} },
    { "id": "a1", "type": "action", "data": { "action_type": "graphql",
        "config": { "...": "see §3" } } },
    { "id": "c1", "type": "condition", "data": { "predicate": { "...": "see §4" } } }
  ],
  "edges": [
    { "from": "n_start", "to": "a1" },
    { "from": "c1", "to": "a2", "branch": "then" },
    { "from": "c1", "to": "a3", "branch": "else" }
  ]
}
```

- `version` MUST be `2`.
- Node types: `entry` (exactly one), `action`, `condition`, `terminal`
  (implicit — a node with no outgoing edge ends the walk; the canvas hides
  explicit terminal nodes).
- `data.on_error`: `"continue"` (default) | `"stop"` — on action nodes; `stop`
  halts the walk when the action errors.
- Edges: `{ "from", "to", "branch" }`; branch is `then` | `else` and only
  valid from condition nodes.
- The walker stops after 1000 node visits (loop guard). Cycles are also
  rejected at save time (`FORM_FLOW_CYCLE`).

### Dry run

`dryRunFlow(flow, context)` executes a flow document WITHOUT saving it —
`flow` is the document as a JSON string (the same string
`saveFlowDoc` takes; unsaved drafts are fine), `context` is a JSON
string describing a mocked request:

```json
{
  "method": "POST",
  "body": "{\"name\":\"Ann\",\"email\":\"a@b.c\"}",
  "bodyContentType": "application/json",
  "headers": { "Content-Signature": "…" },
  "member": { "id": "m_1" },
  "params": { "slug": "post-1" },
  "ip": "1.2.3.4",
  "userAgent": "curl",
  "graphqlResults": { "dm_find": { "orders": [{ "orderNo": "B-1", "status": "new" }] } },
  "httpResults": { "captcha": { "success": true, "hostname": "acme.test" } }
}
```

- `body` is parsed per `bodyContentType` into `form.*` / `json.*` / `xml.*`
  (plus `raw_body`); `method`, `headers`, `member`, `params`, `ip`,
  `userAgent` seed the matching Liquid context keys.
- `graphqlResults` mocks graphql nodes by `result_key`, `httpResults` mocks
  `result_key` http_post nodes (the captcha guard answers
  `httpResults.captcha.success`) — no HTTP is made (`mocked: true` on the
  step).
- `send_email` / `notify_telegram` / `http_post` WITHOUT `result_key` are
  SUPPRESSED — recorded in `suppressed`, never executed. No mail leaves, no
  webhook fires.
- `set_cookie` / `redirect` / `respond_json` / `render` collect directives:
  JSON-encoded objects in `directives` (a `respond_json` body is truncated
  to 512 chars). `render` renders against the REAL template store — a
  missing template is an informative step error, not a suppression.
- Steps mirror the live VisitRecord stream, one per node:
  `{node, type, status, branch, mocked, suppressed, detail}`. Condition
  diagnostics ride `detail`, including
  `field_not_resolved: <field names> (branch=…)` when a clause's field
  resolved empty — read it before blaming the data.

Recommended loop: assemble → `dryRunFlow` 3–4 scenarios (happy path, empty
body, member vs anonymous, invalid signature) → fix →
`saveFlowDoc` (save draft) → verify on the preview host →
`publishFlowDoc`.

## 2. Liquid context

Every string in a flow that gets rendered (graphql `query`/`variables`,
`body_template`s, cookie/redirect values, condition values where noted) is a
Liquid template over this context:

| Path | Contents | Available |
|---|---|---|
| `form.<name>` | fields of a form-encoded/multipart POST | always on POST |
| `json.<name>` | body of an `application/json` POST (objects nest) | JSON POSTs |
| `xml.<node>` | body of an XML POST, keyed by root element (repeated siblings → arrays, attributes → `@name`) | XML POSTs |
| `raw_body` | the EXACT request body bytes (up to 1 MB) — webhook signatures verify against the byte-level body, not the parsed maps | JSON/XML POST flow runs with a body ≤1 MB (form/multipart POSTs expose no raw_body — their signatures ride form.*) |
| `query.<name>` | URL query params | GET flow pages |
| `results.<key>` | data of graphql nodes by `result_key` | after that node ran |
| `member.<field>` | signed-in member payload (`member.id`, …) | any flow run with a session (sync and async) |
| `params.<name>` | route `:path` segments (`/blog/:slug` → `params.slug`) | any run whose request matched a parameterized route |
| `headers.<Name>` | request headers (canonical Go names, e.g. `headers.X-Api`) — usable in condition predicates, templates and variables | every POST flow run (binding and legacy `_form` alike) |
| `method` | HTTP method of the request (`GET` / `POST`) — usable in condition predicates, body templates and graphql variables | all flow executions (sync and async) |
| `ip` / `user_agent` | caller IP (as the server sees it) and User-Agent — IP-allowlist gates ride a plain `eq` on `ip` | all flow executions (GET pages and POST runs, sync and async) |
| `token_subject` | member id pre-resolved from `?token=` | GET pages with a token |
| `site.version` / `site.query` / `site.params` | site generation counter, query, path params | GET flow pages |
| `cookies.<name>` | sealed session cookie payloads | GET flow pages |

GET flow pages also mirror the query map into `form.*`.

Body namespaces: a browser form populates `form.*`, a JSON
client `json.*`, an XML client `xml.*`.
Unsupported request formats get `415 UNSUPPORTED_MEDIA_TYPE` instead of a
silently-empty submission.

Empty vs missing is TRANSPORT-dependent, and skip-empty guards must
survive both:

- a browser form submits every input — an unfilled field arrives as `""`
  (key present, value empty);
- a JSON/XML client can OMIT a key — the field is then missing (`nil`),
  and JSON values may be numbers/booleans/objects, not just strings.

The assign+strip recipe in §2.1 treats missing, empty and whitespace-only
alike; a bare `!= ""` lets an OMITTED key through (nil ≠ ""). And an
unquoted `{{ field }}` interpolation renders missing/empty as NOTHING —
inside a JSON `body_template` that hole is invalid JSON (`"productId":,`)
and the receiving API answers 400.

## 2.1 Assembling JSON bodies in Liquid (`body_template` + `| json`)

A `respond_json` / `http_post` / `notify_telegram` `body_template` is a
Liquid STRING that must be VALID JSON after rendering. You assemble the
object by hand, key by key, and pass every VALUE through the `| json`
filter — never interpolate a visitor-controlled string into JSON bare:
one quote or newline inside the value breaks the whole body.

What `| json` does (executed against the sandboxed flow engine):

| Input value | `{{ v | json }}` emits |
|---|---|
| `"Иван"` | `"Иван"` — quoted, `"`/newline/backslash escaped |
| field MISSING (nil) | `null` (unquoted) |
| `"5"` (form fields arrive as strings) | `"5"` — a JSON STRING |
| `"5" \| plus: 0` | `5` — a JSON NUMBER (use `plus: 0.0` to force float) |
| `true` / number value | `true` / the number, as-is |

Canonical pattern — optional keys carry the comma INSIDE the `{% if %}`,
so an omitted key never leaves a dangling comma. Guard with the
assign+strip recipe — NOT `!= blank`, which only means "the key is
present" (see the trap right below):

```liquid
{% assign ln = form.lastName | strip %}
{"firstName":{{ form.firstName | json }}{% if ln != "" %},"lastName":{{ ln | json }}{% endif %}}
```

### The `blank` trap (this is NOT Shopify Liquid)

The ONLY condition literals on this engine (osteele/liquid v1.8.1) are
`true` / `false` / `nil`. **`blank` is not a keyword** — it parses as an
undefined variable, i.e. `nil`. So `form.lastName != blank` means "the
field is PRESENT", not "the field is non-empty". Verified behavior:

| `form.lastName` | `!= blank` | `!= nil` | `!= ""` | `assign`+`strip` → `!= ""` |
|---|---|---|---|---|
| missing | false | false | **TRUE** (nil ≠ "") | false |
| `""` | **TRUE** | **TRUE** | false | false |
| `"   "` | **TRUE** | **TRUE** | **TRUE** | false |
| `"Петров"` | true | true | true | true |

To skip everything effectively empty (missing, empty, whitespace-only) use
the assign+strip recipe — and note the row above: **a bare `!= ""` lets a
MISSING field through** (nil ≠ "" is true):

```liquid
{% assign ln = form.lastName | strip %}
{% if ln != "" %},"lastName":{{ ln | json }}{% endif %}
```

### Engine facts for body templates (all verified live)

- **Trim filters**: `strip` (both sides — this engine's "trim"), `lstrip`,
  `rstrip`. There is **no filter named `trim`**.
- `| strip | json` on a MISSING field yields `""` (strip coerces nil to
  the empty string first) — bare `| json` on a missing field yields
  `null`. Pick per what the receiving API tolerates.
- **A filter inside an `{% if %}` expression is a SYNTAX ERROR** —
  `{% if form.x | strip != "" %}` fails the render outright. Apply
  filters in `{% assign %}` first (as above).
- Whitespace control works: `{%- assign x = … -%}` eats the surrounding
  newlines — put assign lines on their own lines without polluting the
  body with leading whitespace.
- This is the flow-action engine, SEPARATE from the site renderer's
  (liquid-guide.md): no `{% include %}` (refused), no `date_ru`/`push`;
  it adds the digest sign filters (§4), `signed_token` (send_email
  `body_template` only) and the helper filters below. The standard filter
  list is otherwise the same as liquid-guide.md's.

### Helper filters (flow engine only; all behavior verified live)

Pure data-shaping helpers registered alongside the digest filters — no
filesystem, no network, nothing executed; they only transform values the
template already holds:

| Filter | Usage | Result |
|---|---|---|
| `digits` | `{{ form.phone \| strip \| digits }}` | `+7 (900) 123-45-67` → `79001234567` — keeps ASCII digits only |
| `number` | `{{ form.qty \| strip \| number \| json }}` | `"990.50"` → `990.5` — a JSON NUMBER. STRICT: `"5px"` / `"1 OR 1=1"` → nil (`null` via `\| json`); fallback via `\| default: 0` |
| `base64_encode` | `{{ form.pair \| base64_encode }}` | `user:pass` → `dXNlcjpwYXNz` (std padded — Basic-auth headers) |
| `base64_decode` | `{{ form.b64 \| base64_decode }}` | decoded string; invalid input → `""` (std alphabet, padded) |
| `now` | `{{ "" \| now }}` | current Unix seconds as an int — input ignored; `{"ts":{{ "" \| now }}}` |
| `uuid` | `{{ "" \| uuid }}` | fresh UUIDv4 per render (idempotency keys) — input ignored |

These do not widen the trust boundary: the flow engine renders strings
and never touches a database; the only data path into storage is the
`graphql` action, which was already parameterized and identifier-
whitelisted before these filters existed — they add no new reachability.

### Arithmetic filters (stock; verified live on the flow engine)

`plus`, `minus`, `times`, `divided_by`, `modulo`, `abs`, `ceil`, `floor`,
`round` — all present (there are NO `mul`/`sub` aliases: multiplication
is `times`, subtraction is `minus`). Gotchas verified against the stock
osteele/liquid v1.8.1 implementation this engine runs (the site
renderer's int64-corrected overrides do NOT apply here):

- **Numeric strings take the FLOAT path** — `isIntegerType("5")` is
  false, so `"9007199254740993" | plus: 0` → `9007199254740992`
  (precision loss above 2⁵³). Form fields are always strings: fine for
  prices/quantities, do not route large ids through arithmetic filters.
- **`plus`/`times` coerce garbage and nil to 0 SILENTLY** —
  `{{ form.qty | plus: 0 }}` on a missing field or `"abc"` yields `0`,
  not an error. Use the strict `number` filter when "absent" must stay
  `null` instead of becoming `0`.
- **`divided_by` ERRORS the render on a string, nil or zero divisor** —
  `{{ "7" | divided_by: "2" }}`, a missing field (`number` → nil) and
  division by 0 all fail the whole template. Make BOTH sides numbers via
  `number`, and guard the divisor:

  ```liquid
  {% assign total = form.total | number %}
  {% assign parts = form.parts | number %}
  {% if parts %}{{ total | divided_by: parts }}{% else %}0{% endif %}
  ```
- Int/int `divided_by` truncates (`"100" | number | divided_by: "3" |
  number` → `33`); any float operand switches to float math
  (`7 | divided_by: 2.0` → `3.5`).
- `ceil`/`floor` return ints; `round: 2` → `2.567` → `2.57`.

### Complete example (CRM contact from a form)

```liquid
{%- assign fn = form.firstName | strip -%}
{%- assign ln = form.lastName | strip -%}
{%- assign ph = form.phone | strip | digits -%}
{%- assign em = form.email | strip -%}
{"firstName":{{ fn | json }}{% if ln != "" %},"lastName":{{ ln | json }}{% endif %}{% if ph != "" %},"phones":[{{ ph | json }}]{% endif %}{% if em != "" %},"emails":[{{ em | json }}]{% endif %}}
```

All four values are trimmed (`phone` additionally drops everything but
digits — `+7 (900) 123-45-67` → `79001234567`); `firstName` is always
present (missing → `""`); `lastName`/`phones`/`emails` appear only when
non-empty after trim; single values wrap into one-element arrays
(`"phones":["…"]`).

## 2.2 Passing data between requests

The Liquid context is PER RUN. `results.*`, the parsed body, everything in
the §2 table lives and dies with one HTTP request — there is no flow-local
server-side state, and nothing needs "invalidating" between requests
because nothing survives them. Cross-request data goes through one of
these channels:

| Channel | Write side | Read side | Use for |
|---|---|---|---|
| **Entities — the canonical store** | `graphql` action `createEntity`/`updateEntity`; keep the id from `results.<key>` | `graphql` action query, or a GET flow page's query, matched by `id`/slug/your own handle field | anything that must outlive the request: orders, applications, issued tokens |
| **Sealed cookie** | `set_cookie` directive (HMAC-sealed; `session:true` is reserved for the member session — max one per flow) | `cookies.<name>` on GET flow pages; the members-only entry gate resolves its gate cookie from the same map | small visitor-scoped hand-off (wizard step, cart id). Sealed = integrity-checked, NOT encrypted — never store secrets in it |
| **Member session** | member-auth presets issue the `session:true` cookie | `member.*` in every run + `$member` query variables, resolved server-side from the cookie | identity-scoped data and private pages (member-auth.md) |
| **Redirect + params** | `redirect` directive (default 303, Liquid-rendered `location`) | next request: `query.*` (mirrored into `form.*` on GET pages), `params.<name>` for `:path` segments | PRG hand-off right after a POST. The URL is visitor-visible — no secrets, no long payloads |
| **External system** | `http_post` to the peer's create/add endpoint; store the peer's returned id in an entity | the peer's find/get endpoints by that id | when the peer owns the record — create the order THERE, keep the id HERE |

Rules of thumb:

- **Persist first, redirect second.** Write the entity, THEN redirect to a
  page that re-queries by the stored id — a refresh re-reads instead of
  re-submitting (and never duplicates the write).
- The run journal (`flow_runs`) is audit, not storage: it records what a
  run did, but no later run can query it.
- Secrets never travel through cookies or URLs — `secret://` refs (§5)
  resolve server-side only.

## 2.3 Pick the shape by CALLER: browser form vs JSON endpoint

The most common mis-build: a route is written as a browser form flow
(`form.*` fields + `redirect` answer) when the caller is a PROGRAM.
Decide by WHO calls the route, not by habit:

| Caller | Build | Body arrives in | Answer |
|---|---|---|---|
| A human fills an HTML form on a site page | form flow | `form.*` (every input present; unfilled = `""`) | `redirect` (PRG) or `render` |
| A script / integration / another service POSTs JSON | JSON handler | `json.*` (keys may be omitted → nil) + `raw_body` | `respond_json` (+ `status`) |
| An external provider's signed webhook | JSON/XML handler | `json.*`/`xml.*` + `raw_body` — signatures verify against exact bytes, never parsed maps | `respond_json` 200 |
| Your own page's `fetch()` from custom JS | JSON handler | `json.*` | `respond_json`, parsed in JS |

Why the JSON handler is usually the better build for program callers:

- the answer is machine-readable: data back to the caller
  (`{"ok":true,"orderId":…}`), an explicit `status` (200/400/…), and no
  redirect-following to interpret;
- it verifies in ONE step (POST the JSON, assert the JSON answer) — a form
  flow's PRG redirect is invisible noise to curl and to agents;
- no PRG semantics to emulate — a program does not re-POST on "refresh";
  idempotency is the caller's concern, give it an idempotency key field.

Keep one transport contract per route. A route that genuinely must accept
both shapes reads `form.*` AND `json.*` with the §2.1 assign+strip guards
(the recipe survives `""` and nil alike). And never build a browser-form
flow "for testing" a program contract — test the real JSON contract
directly. `respond_json` config (incl. plain-data `body` variant and
`status`) is in §3.

## 2.4 JSON structures in Liquid: reading, looping, building

All rows verified live against the flow engine (osteele/liquid v1.8.1) —
these are actual outputs, not Shopify-Liquid assumptions.

Reading:

| Expression | Behavior |
|---|---|
| `{{ json.order.id }}` | dot paths walk nested objects |
| `{{ json.items[1].productId }}` | numeric index into an array, then dot paths |
| `{{ results.lookup.clients[0].name }}` | graphql results are plain arrays — same indexing |

Looping:

- `{% for i in json.items %}` iterates arrays of objects; `{{ i.productId }}`
  reads fields, `i` itself is a map (serialize with `| json`).
- `forloop.last` / `forloop.index0` work. Comma-separated assembly is
  `{% unless forloop.last %},{% endunless %}` inside the loop.
- A `{% for %}` over a MISSING or EMPTY array renders NOTHING — no error.
  Looping unconditionally is safe.
- Do NOT `{% for %}` over an object/map — the loop variable comes out as a
  key-value concatenation (`id55`), not a key. Read fields directly.

"Has items" check — the mine: `.size == 0` is TRUE for an empty array but
**FALSE for a missing key** (`nil.size ≠ 0` — nil wins again, same family
as the `blank` trap). `size > 0` is correct for all three states (missing →
false, empty → false, non-empty → true):

```liquid
{% if json.items.size > 0 %}…{% endif %}
```

Building JSON (extends §2.1):

- Per element, `{{ i | json }}` serializes a whole map or nested array —
  do not hand-build nested objects field by field.
- Canonical array assembly:

```liquid
[{% for i in json.items %}{{ i | json }}{% unless forloop.last %},{% endunless %}{% endfor %}]
```

- `{{ json.items | json }}` embeds an entire structure as-is. Map keys
  serialize alphabetically — order carries no meaning in JSON objects, but
  do not rely on input key order.
- `first`/`last` work BOTH as filters (`{{ json.tags | first }}`) and as
  properties (`{{ json.tags.first }}`).

## 3. Actions

| `action_type` | Config (exact keys) | Response effect |
|---|---|---|
| `graphql` | `query` (required, Liquid), `variables` (object; string leaves are Liquid-rendered), `result_key` (stores the response into `results.<key>`; without it the result is discarded), `library_name` (metadata only), `secret_vars` (array of form field names — see §5). CAUTION: a `query` written inline is a PRIVATE COPY — editing the query LIBRARY entry later does NOT update flows that carry their own inline query text; fix the flow itself (or re-point it at the library) | — |
| `render` | `template` (required, path of a SITE template, NOT Liquid-substituted). The template owns its shell: capture the body into `content` and `{% include "base" %}` — see liquid-guide.md "Base template" | HTML 200 |
| `respond_json` | `body_template` (Liquid string — the interpolation path) OR `body` (plain data: object/array serialized to JSON as-is, string used verbatim, NO Liquid; `body_template` wins when both are set), `status` (100–599, default 200), `content_type` (default `application/json; charset=utf-8`) | JSON answer |
| `send_email` | `smtp` (object, REQUIRED — the author's own server: `host`, `port`, optional `username`/`password`, `from`, optional `from_name`), `to` (string or array, Liquid), `cc`, `subject`, `body_template` (Liquid plain text; `signed_token` works ONLY here) OR `template` + `layout` (SITE files rendered with flow context → HTML body; no signed_token) | — |
| `http_post` | `url`, `body_template`, `headers` (values Liquid), `timeout_sec` (default 10), `result_key` (stores the parsed JSON response under `results.<key>`), `secret_params` (name→value pairs appended url-encoded to the body; values are meant to be whole-string `secret://` refs, which resolve server-side fail-closed) | — (SSRF-guarded client) |
| `notify_telegram` | `webhook_url`, `message_template`, `chat_id` (optional) — other URLs receive Slack-style `{"text":"…"}`; an `api.telegram.org` Bot API URL additionally REQUIRES `chat_id` and receives `{"chat_id":…,"text":…}`. No bot-token field anywhere — tokens live inside the webhook URL | — |
| `set_cookie` | `name` (required, no Liquid), `value` (Liquid), `ttl_days` (default 0; `<= 0` DELETES the cookie — logout), `path` (default `/`), `session` (bool; at most ONE `session:true` per flow) | Set-Cookie (HMAC-sealed — see member-auth.md) |
| `redirect` | `location` (required, Liquid), `status` (300–399, default 303) | redirect |
| `condition` (action) | `field`, `operator`, `value` — a single clause; non-match stops the walk. Prefer a `condition` NODE (§4) | — |

Flows carrying any of `set_cookie` / `redirect` / `respond_json` / `render`
run **synchronously** — their directives shape the HTTP response. All other
flows run asynchronously after the response (30s hard timeout, best-effort).

## 3.1 `async` — fire-and-forget task nodes

An ACTION node may set `"async": true` on its `data` (a sibling of
`action_type`, NOT a config key). The executor honors it ONLY on
`send_email`, `http_post`, and `notify_telegram`; on every other type it is
ignored (forward-compatible with docs saved by newer editors).

What async signs you up for:

- **The walk does not wait.** Dispatch returns immediately; the next node
  runs at once. Use it for slow third-party endpoints that must not delay
  the visitor's response.
- **Context freezes at the node's position.** Its Liquid strings render
  against a snapshot taken at dispatch time. Results of LATER nodes are
  invisible to it — put data-fetching nodes BEFORE an async node that needs
  them (`async:true` on a mid-flow graphql would be ignored anyway).
- **Its own result is bound nowhere.** No `results.<key>` — if a later node
  consumes the response, do NOT make this node async.
- **Failures cannot stop the flow.** The background task writes its own
  (single) action-journal record (`form_action_runs`) when it finishes; even
  `on_error: "stop"` cannot halt anything by then.
- **At-most-once within process lifetime.** A crash/restart between dispatch
  and completion silently loses the task — this is not a job queue.
- Background cap: the same 30s hard timeout as every other execution path.

## 4. Condition nodes

```json
{ "type": "condition", "data": { "predicate": {
    "combinator": "and",
    "clauses": [
      { "field": "form.password", "operator": "bcrypt_matches",
        "value": "{{ results.login.members[0].password }}" }
    ] } } }
```

- Operators (exactly eleven): `eq, ne, gt, gte, lt, lte, contains, matches,
  bcrypt_matches, token_valid, digest_matches`.
- The `value` is Liquid-rendered for `bcrypt_matches`, `token_valid` and
  `digest_matches` (their payload interpolates graphql results, raw_body or
  assembled provider fields); every other operator compares literally.
- **A clause `value` may be a `secret://<name>` reference** — the whole
  value (`{ "field": "headers.X-Pay-Secret", "operator": "eq",
  "value": "secret://pay_sig" }`) or the `<secret>` side of an hmac
  `digest_matches` spec. Resolved server-side before evaluation; see §5
  (Secret references). An unknown name denies the clause fail-closed and
  the run records `secret_not_resolved: secret://<name>`.
- **`digest_matches` — the universal webhook/provider signature gate**
  (value is Liquid-rendered). `field` = where the signature arrives
  (`headers.Content-Signature`); `value` = `"<spec>|<payload>"`:
  - plain digests — `md5`, `sha1`, `sha256`, `sha512` — payload is the
    input string (assemble the provider's concatenation in Liquid; the
    password lives inside the string, as their scheme dictates):
    `"sha256|{{json.OutSum}}:{{json.InvId}}:{{json.Password}}"`.
  - HMAC digests — `hmac-md5`, `hmac-sha1`, `hmac-sha256`,
    `hmac-sha512` — payload is `<secret>|<input>`:
    `"hmac-sha256|whsec_…|{{raw_body}}"`.
  - Signature side: hex (case-insensitive) and base64 (padded or not) both
    accepted; Stripe's `t=…,v1=…[,v1=…]` rotation composite and bare
    `sha256=`/`v1=` prefixes are unwrapped automatically. Covers Stripe,
    CloudPayments, Tinkoff; legacy Robokassa rides `md5`. Mismatch / missing
    anything → false (fail-closed). Replay protection: a plain
    `matches`/`ne` clause on the timestamp you parse out of the body. No
    per-provider helpers — the platform only hashes.
- **The same digests exist as Liquid FILTERS for SIGNING your own payloads**
  (outgoing http_post webhooks, payment-link parameters — Robokassa
  requires the MERCHANT to compute the signature). Filter names mirror the
  specs (dashes → underscores), output is hex:
  `{{ raw_body | sha256 }}`, `{{ s | hmac_sha256: "secret" }}` (also md5,
  sha1, sha512, hmac_md5, hmac_sha1, hmac_sha512). An empty secret or a nil
  input yields an EMPTY string, never a digest of nothing. Flow-engine
  filters only — the public site template engine does not expose them.
- **`field` supports array indexing**: a dotted map path with `[N]` subscripts
  at any depth — `results.dm_find.orders[0].status` resolves the first
  order's status. A malformed or out-of-range subscript resolves to EMPTY →
  the clause is false (fail-closed), never an error. (Newer platform
  behavior; on releases up to v1.1.59 indexed fields silently resolved
  empty — filter in the GraphQL `where` clause when targeting those.)
- Unknown operator or evaluation error → `false` (fail-closed).
- **Empty-path diagnostics:** when a clause's `field` resolves to NOTHING
  (typo, wrong namespace, upstream node never populated it), the run log
  (`flowRuns`) gets a `condition` record
  `field_not_resolved: <field names> (branch=…)` — check it when a
  condition mysteriously takes its else branch; field NAMES only are logged,
  never values.
- A condition node has exactly one `then` edge, at most one `else` edge;
  no `else` edge → the walk ends when it doesn't match.
- `combinator`: `"and"` (default; empty = true) or `"or"` (empty = false).
- **Cookies are a generic condition operand**: on GET-flow pages
  the `cookies.<name>` namespace carries the sealed session payload keyed by
  cookie name, so a clause `{ "field": "cookies.member", "operator": "ne",
  "value": "" }` gates on a signed-in member, and any other cookie can be
  checked the same way. The ENTRY node's gate (below) is the packaged
  version of this check.

### The entry gate (members-only branch)

```json
{ "type": "entry", "data": { "gate": { "cookie": "member", "redirect": "/login" } } }
```

When `gate` is set, the walk STARTS with a cookie check: present → the
`then` edge (the page's normal scope). Absent → the else behavior, which
is EITHER the gate's own `redirect` path (implicit 302, no `else` edge
needed — the canvas shows a single exit, which IS the then-branch) OR a
custom `else` scope (leave
`redirect` empty and wire a redirect/render node to `else`; an unwired
`else` ends the walk WITHOUT the gated scope — it never falls through).
The editor mirrors the gate into the route's `members_only`/`member_cookie`/
`login_path` metadata on save, so the pre-flow guard (GET redirect, POST
POST denial — the SAME rules on preview as on live) and the canvas
branch stay one mechanism.

**The graphql result contract (live-probed):** dynamic-entity plural queries
return the LIST DIRECTLY — there is no `nodes{}` wrapper in the schema. A
login fetch is:

```graphql
query($q: String!) { members(where: {email: {eq: $q}}, limit: 1) { id password } }
```

and the list path is `results.<key>.<plural>[0].<prop>` — e.g.
`{{ results.login.members[0].password }}`. Singular lookups read
`results.<key>.<singular>.<prop>`.

## 5. Secrets

- `secret_vars` on a graphql node: names of form fields whose values must
  never rest in the submissions store — the render layer replaces them with
  `"[redacted]"` in the PERSISTED submission (the flow itself always sees the
  original). The candidate side of every `bcrypt_matches` clause is scrubbed
  automatically. Scrubbing walks nested maps/arrays (JSON/XML bodies).
- bcrypt hashes never reach responses, emails, or cookies: the platform
  rejects such attempts (`rejectBcryptLeak`). Treat it as a guarantee, not a
  dare.

### Secret references — `secret://<name>`

A STRING leaf whose WHOLE value is `secret://<name>` — in an action node's
config, or in a condition clause's `value` — is resolved server-side into
the stored secret at execution time. Use it wherever a credential would
otherwise sit in the document:

- `send_email` → `smtp.password`;
- `http_post` → `headers` values;
- **condition clauses** → the whole `value`
  (`{ "field": "headers.X-Pay-Secret", "operator": "eq", "value": "secret://pay_sig" }`);
- `digest_matches` → the `<secret>` side of an HMAC `value`
  (`"hmac-sha256|secret://robokassa|{{raw_body}}"`).

Rules:

- Whole value ONLY — `secret://` embedded mid-string is not resolved (the
  one documented exception: the hmac `<secret>` segment of a
  `digest_matches` spec, above).
- Secret names match `^[a-zA-Z0-9_-]{1,128}$`; a tenant holds at most 100
  secrets of at most 8 KB each (`SECRET_VALUE_TOO_LARGE`). An hmac operand
  secret must not contain `|` — the spec is pipe-delimited.
- Fail-closed, two shapes: an unknown name ERRORS the action
  (`SECRET_NOT_FOUND`); in a condition the clause compares against an
  impossible value — `eq` (and any allow-shaped comparison) denies — and
  the run records `secret_not_resolved: secret://<name>` so the denial is
  diagnosable, not silent.
- References are never logged — dry-run traces and `flowRuns` records
  show `secret://<name>`, never the value. Dry-run evaluates conditions
  against the REAL stored secret (the branch decision is live-accurate);
  only response-affecting action configs keep the reference in directives.
- The store is WRITE-ONLY: secrets are written with
  `upsertTenantSecret(name, value)` (api-reference.md §7) and no API ever
  returns a stored value; to change one, re-enter it (upsert overwrites).

## 6. Route bindings (the route owns the flow)

Route input keys: `path`, `methods[]`, `sitemapQuery`, `cache{defaultTtl}`,
`flows[]`, `members_only`, `login_path`, `member_cookie`.

A binding: `{ "handle": "<handle slug>", "methods": [...], "header_name"?,
"header_value"? }` — snake_case in the render layer; GraphQL clients write
`SiteRouteFlow { handle methods headerName headerValue }` (camelCase), the
API translates.

```graphql
mutation {
  saveSiteRoute(route: {
    path: "/blog/:slug"
    cache: { defaultTtl: 300 }
    flows: [
      { handle: "blog_page", methods: ["GET"] }
      { handle: "blog_api",  methods: ["GET"], headerName: "X-Api", headerValue: "v1" }
    ]
  }, draft: true) { status }
}
```

Dispatch of `GET /blog/x` with header `X-Api: v1`:
1. filter bindings by method;
2. a header-conditioned binding whose header matches WINS over the
   unconditional one regardless of list order (`header_value` empty = header
   presence is enough);
3. otherwise the first unconditional binding;
4. if the chosen header-conditioned binding runs but does not answer
   (no respond_json/render/redirect) → `500 FLOW_NO_RESPONSE`; an
   unconditional binding that doesn't answer 404s the path (pure
   `submitted`/`utm_*` query strings get 302-stripped instead) — there is
   no page-renderer fallback.

Rules:
- bindable methods: `GET|POST|PUT|DELETE` (HEAD is served as GET, OPTIONS
  goes to the CORS preflight — neither is bindable);
- EVERY route MUST have ≥1 binding; a binding MUST have ≥1
  method;
- one handle may live on many routes and multiple times on one route;
- creating a flow on an occupied path merges methods (occupied = other
  bindings' methods + GET when a template exists; nothing free → GET in the
  shadow with a warning);
- a non-matched method on a public path → `405 METHOD_NOT_ALLOWED` (an
  unmatched POST — the route has no binding for it — 404s);
  PUT/DELETE reach flows ONLY through bindings;
- a dead binding (deleted handle) fails open — a typo in `handle` is not
  visible immediately. Check `flows` after renames.

## 7. Flow pages and the response cache

A flow answers a page when its `render` node produces the HTML (the
template-less route pattern above). Before rendering, every `results.<k>` is
also lifted into the page binding `<k>.data` (reserved names are not
touched).

- Path params: `:name` segments (segment count must match; a static segment
  beats a `:param` at the same position). They reach templates and graphql
  variables as `params.<name>`.
- The response cache keys an anonymous 200 answer that ended in
  respond/render with NO set_cookie and NO redirect: key =
  `tenant:<t>:flowresp:<generation>:<path>:<handle>` + variable prefixes
  `p:` (path params), `q:` (query values, capped at 256 runes — empty values
  dropped), `h:` (the header value, only for header-conditioned bindings).
  `submitted` and `utm_*` params share one entry (feature, not bug).
- Cache hits are FREE — the lookup happens before the rate limits. `X-Cache:
  hit` marks a hit; a freshly computed flow answer carries `X-Cache: miss`.
- `cache.defaultTtl` unset or `0` = live mode: every request executes the
  flow and nothing is cached. Use 0 for member areas (responses of signed-in
  visitors are NEVER cached anyway), 300 for showcases.
- Signed-in members never get cached answers; any cookie-planting or
  redirecting answer is not cached; non-200 answers are not cached. If a page
  "flickers" between states, check whether the flow sets a cookie.
- TTL lives on the ROUTE, not on the binding.
- Any site mutation (publish, edits) bumps the site generation and orphans
  all cached answers.

## 8. Export / import (`.dynflow.json`)

Export one flow (or all flows) from the Flows tab; import on another tenant.

```json
{
  "format": "dynapi-flow",
  "version": 1,
  "exportedAt": "2026-07-28T00:00:00.000Z",
  "handle": { "slug": "login", "name": "Login", "enabled_cors": false, "allowed_origins": [] },
  "routes": [{
    "path": "/login", "methods": ["GET", "POST"], "cache": { "defaultTtl": 300 },
    "flows": [{ "handle": "login", "methods": ["POST"] }]
  }],
  "flow": { "version": 2, "nodes": [], "edges": [] },
  "queries": [{ "name": "cats", "query": "{ pages { title } }" }],
  "templates": [{ "path": "page.liquid", "content": "..." }]
}
```

- Format literals: `dynapi-flow` (single) / `dynapi-flows` (bundle, array
  `flows[]`, file `flows.dynflows.json`).
- File names: `<slug>.dynflow.json`.
- Export carries flow + routes + referenced queries (by `library_name`) +
  referenced templates (draft-first, live fallback). Entity DATA never
  travels.
- Import: queries import only when the name is free (first-wins); templates
  only when missing (always draft); an occupied path is skipped with a toast;
  on a free path all exported methods of the handle merge into ONE binding of
  the new slug. A malformed file fails client-side (`FLOW_IMPORT_INVALID`).

## 9. Validation summary (save-time)

`FORM_FLOW_INVALID` family: wrong `version` (must be 2), not exactly one
entry node, duplicate node ids, then/else edge rules, broken edges, a second
`session:true` set_cookie, an unknown predicate operator, cycles
(`FORM_FLOW_CYCLE`), predicate shape (`FORM_FLOW_CONDITION_BAD`).
