# Wire Flow — download, upload, test

Wire Flow is TilliT's embedded BPMN workflow builder. A workflow is **BPMN 2.0 XML
plus a tiny config object**, stored in S3 — not a schema-driven config entity — so
`import`/`diff`/`deploy` cannot manage it. `tllt wireflow` is its own command
family.

```bash
tllt wireflow list                              # what exists on the tenant
tllt wireflow pull                              # every flow -> files
tllt wireflow pull manage_orders
tllt wireflow validate manage_orders            # compile only, deploy nothing
tllt wireflow push manage_orders                # save + deploy
tllt wireflow push manage_orders --activate     # ...and point the schedule at it
tllt wireflow test manage_orders --input '{}'   # run once, report the execution
tllt wireflow versions manage_orders
tllt wireflow activate manage_orders 3
tllt wireflow create --file new.bpmn            # then push it
```

Snapshot layout mirrors `import`, so a flow is reviewable and diffable in git:

```
{profile}/wireflow/{id}/workflow.bpmn   the BPMN — the source of truth
{profile}/wireflow/{id}/_meta.json      {id, name, description, type}
```

## The API surface

The Wire Flow API sits **beside** the `/api` gateway, not under it:

```
https://{tenant}.tillit-stage.cloud/{region}/wire-flow/api/workflows      OK
https://{tenant}.tillit-stage.cloud/{region}/api/wire-flow/api/workflows  403
```

The wrong shape returns a SigV4 error (`Authorization header requires
'Credential' parameter`) because it lands on a different authorizer — that error
means *bad path*, not bad credentials. It accepts **the same profile auth as the
other APIs** (basic or api-key bearer, plus `tillit-tenant`); no browser session
token is needed.

Routes the CLI uses:

| Method | Path | Notes |
| ------ | ---- | ----- |
| `GET` | `workflows` | list |
| `GET` | `workflows/{id}` | `{id, bpmnXml, config}` |
| `POST` | `workflows` | create; reads name/description/type from the BPMN's `wire:` process attributes. Does **not** deploy |
| `PUT` | `workflows/{id}` | save + deploy. Query: `activate=true`, `draft=true`, `asl-preview=true` |
| `DELETE` | `workflows/{id}` | |
| `GET` | `workflows/{id}/versions` | `{versions:[{versionNumber, creationDate, isActive}], activeVersion}` |
| `POST` | `workflows/{id}/activate` | `{versionNumber?}` — defaults to latest |
| `POST` | `invoke/{id}` | body becomes the workflow input (max 256 KB) |
| `GET` | `credentials` | HTTP tasks reference these by `wire:credentialId` |

## Deploy is not activate — the number-one trap

`PUT` deploys a **new version** but leaves the alias alone: the response says
`"activated": false`, and **the timer keeps running the previously activated
version**. Push a fix, watch it not take effect, lose an hour. `push` prints the
activate command when this happens; `versions` shows which version is live.

Pushing identical content does not mint a new version — the version number
returned stays the same, so a presentation-only BPMN change (diagram edges, say)
reports the version it already had.

```bash
tllt wireflow versions <id>                  # * marks the live version
tllt wireflow push <id> --activate           # deploy and go live in one step
tllt wireflow activate <id> <n>              # promote an already-deployed version
```

`versions` reporting `active version: none` means the workflow has never gone
live: it is deployed, `test` will run it, and the timer will never fire it.

Activate deliberately. There is **no deactivate route** — the schedule is torn
down only when the workflow is deleted — so switching on a frequent cron commits
you to whatever it writes until you delete or supersede it. Prefer `test` while
iterating and `--activate` only once the run output looks right.

## `config` on PUT is a strict subset

`SaveWorkflowRequestSchema` accepts exactly `{bpmnXml, config:{name, description,
type}}` and is `.strict()`. Server-owned fields (`stateMachineArn`, `thingName`)
are merged from storage and **rejected if sent** — round-tripping the config from
`GET workflows/{id}` fails with `Unrecognized key(s) in object:
'stateMachineArn'`. The CLI narrows it for you.

## Three failure shapes, three different meanings

| Response | Meaning | What to do |
| -------- | ------- | ---------- |
| `400 {error, issues:[...]}` | The **request body** failed Zod. A malformed file or a CLI bug, not an authoring problem. | fix the payload |
| `400 {errors:[{code, elementId, message}]}` | The **BPMN validator** rejected it. Real authoring mistakes, per element. | fix the BPMN |
| `502 {error:"Saved but deployment failed", detail}` | Validation passed; **Step Functions or the scheduler** rejected the compiled machine. The BPMN **is saved**; the deployed version is unchanged. | fix and push again |

`validate` (`asl-preview=true`) runs the BPMN validator and the ASL generator, so
it catches the middle row cheaply and without deploying. It does **not** catch
the third row: cron expressions are validated by the scheduler and JSONata by
Step Functions, both at deploy time. A 5-field cron and a top-level `$` in
`httpBody` both compile clean and then fail on push.

## Authoring gotchas

These cost real debugging time. Most are invisible until deploy or runtime.

**The envelope contract (ADR 0002).** Every state outputs `{statusCode, body}`.
Downstream reads live under `.body`:

- a `js-executor` receives the full envelope as `input`, so author `input.body`;
- an upstream `wire:outputVariable` named `x` arrives as `vars.x.body` in JS, and
  as `$x.body` in JSONata — a bare `$x` is the whole envelope, not the payload;
- a js-executor's return value is re-wrapped, so returning a bare array makes the
  next task's `$states.input.body` that array.

**`httpBody` is a bare expression.** The generator wraps it in `{% ... %}` itself,
so write `$states.input.body`, never `{$states.input.body}` and never `$.orders`
— a top-level `$` fails with `UNSUPPORTED_JSONATA_EXPRESSION: Reference to '$' at
the top level is not supported`. Omitting `httpBody` on POST/PUT/PATCH is the
documented shorthand for "forward the predecessor payload" and defaults to
exactly `$states.input.body`, which is usually what you want.

> Braces **are** correct on the EDGE worker (`{$states.input.body}` appears in its
> fixtures). EDGE and EXPRESS use different compilers — don't copy expression
> syntax between them.

**The envelope migration rewrites your expressions.** `$states.input.<field>`
becomes `$states.input.body.<field>` unless `<field>` is `body`, `statusCode` or
`headers`. Bare `$states.input` (no trailing `.field`) is left alone — which is
what makes it usable as a Map item reference.

**Never name an `outputVariable` after a word that appears in any field.** The
`OUTPUT_VARIABLE_ENVELOPE_LEAK` check flags a *bare* mention of a declared
variable, and it scans endpoints too: a variable called `ready` collides with the
path `/core/order-manager/ready`, and `assets` with `/core/assets`. Rename the
variable (`queued`, `assetList`).

**Put query params in `wire:httpQueryParams`, not on the endpoint.** It is a JSON
object string:

```xml
wire:endpoint="https://.../core/order-instances"
wire:httpQueryParams="{&#34;asset.id.equals&#34;:&#34;{$states.input}&#34;,&#34;size&#34;:&#34;5&#34;}"
```

Native `http:invoke` **rejects a query string in `ApiEndpoint`**. `aslGenerator`
does split one off the endpoint and merge it for you (explicit
`httpQueryParams` wins on key collisions), so a URL query string works — but the
dedicated field keeps the endpoint readable and avoids escaping `?`/`&` through an
XML attribute. Values interpolate `{$...}` the same way, so per-item Map values
belong here too.

Because both forms compile to the same `QueryParameters` block, moving params out
of a URL is a **semantic no-op** — expect `push` to return the version it already
had rather than minting a new one.

**Map (`wire:itemsPath` + `wire:maxConcurrency`)**, and its three sharp edges:

- inside an iteration `$states.input` is the **raw item**, so make the items
  self-sufficient — plain ids interpolated as `assetId={$states.input}`, or
  objects used directly as the request body via `wire:httpBody="$states.input"`;
- an `outputVariable` on a Map captures the Map's **input, not its results**
  (`applyOutputVariable` runs after `wrapInMapState`, and a Map has no `Output`,
  so it falls back to `STATES_INPUT`). Only the task **immediately after** a Map
  can read its results, as `$states.input`;
- **error boundaries are silently dropped on Map tasks** — `wrapInMapState` copies
  the task into `ItemProcessor` before `attachCatchRules` mutates the original. A
  boundary aimed at the end event is worse than useless: it makes the generator
  synthesise an unreferenced `End_Success` and deployment fails with
  `MISSING_TRANSITION_TARGET: State "End_Success" is not reachable`.

**Interpolating anything else into a URL.** `toJsonataExpr` expands *any*
`{$...}` placeholder, not just `$states.input` — so a value computed once in an
upstream step can be referenced as `{$plan.body.weekStart}`. Keep the `.body` hop
in, both for the envelope contract and because `$var.body` is what the migration
leaves alone.

**Timer triggers.** `wire:timerType="cron"` on the start event, or `"rate"`.
EXPRESS compiles to an EventBridge schedule, so cron is **6 fields** —
`minute hour day-of-month month day-of-week year` — one of
day-of-month/day-of-week must be `?`, and **day-of-week must be numeric** (the
validator's regex allows `0-9 * ? , / - L W #` and no letters, so `MON` is
rejected even though EventBridge itself accepts it; `1`=Sunday, so Monday is `2`).
Evaluation is **UTC**, which is not the site timezone. `rate` is
`<n> minute[s]|hour[s]|day[s]`. The EDGE worker uses 5-field Unix cron instead —
another reason not to copy between the two.

**Step Functions caps each state's payload at 256 KB.** Bulk list fetches blow
this quickly (1,000 rows of a wide DO entity is roughly 1 MB) and the failure
reads `returned a result with a size exceeding the maximum number of bytes
service limit`. Fan out per-key with a Map instead of fetching one big list.

**BPMN DI is separate from execution.** The engine reads `sequenceFlow`; the
canvas draws `bpmndi:BPMNEdge`. Omit the edges and the flow **runs correctly while
looking disconnected** in the editor. Emit one `BPMNEdge` per `sequenceFlow`.

## Watch the DO filter operators too

Not Wire Flow itself, but it bites flows that query DO. On `core/order-instances`,
`scheduledStart.greaterThan` filters, while `.greaterThanOrEqual` and `.gte` are
**silently ignored** and return unfiltered rows — a bound that looks applied and
isn't. Sorting `scheduledStart,ASC` without a bound returns the oldest row in the
tenant, which on a long-lived demo tenant means orders from years ago.

## Typical loop

```bash
tllt wireflow pull manage_orders                # get the current BPMN
$EDITOR bottling-stage/wireflow/manage_orders/workflow.bpmn
tllt wireflow validate manage_orders            # cheap: catches authoring errors
tllt wireflow push manage_orders                # deploy a new version
tllt wireflow test manage_orders                # run it once, inspect the output
tllt wireflow activate manage_orders            # let the schedule use it
```

`test` invokes the deployed workflow for real — against whatever the flow writes
to. On an order-management flow that means real order transitions, so prefer a
throwaway tenant, and run `validate` first.
