API changelog
2026-10-08 — the per-step cap is applied per key by editRunState, and
completeTask answers 400 for an over-cap output.
editRunState used to refuse a patch that was over the cap as a whole, and an
edit that left the run’s whole state over it. It now checks each value of the
patch against the cap, as a step’s output is checked, and nothing else: a patch
that touches two steps is two entries. A run whose accumulated state had grown
past the cap, which the engine allows, can now be repaired by editing; before,
every edit to it was refused. A value over the cap is a 400 naming the key.
Each key is also limited, to the same number of bytes.
completeTask with an output over the cap, or a next naming no step, used to
answer 200 while the step failed. It now answers 400 with the same message
the run records. The step still fails and its onFailure still runs, because
that is committed before the response. Callers that treated any 4xx from
completeTask as “nothing happened” should note that these two are the
exception.
2026-10-06 — a step’s output is now capped at 256 KB, as this document has always said.
FOLIOT_PAYLOAD_MAX_BYTES (default 262144) was documented as the size cap on a
step’s output but was enforced only on a run’s input, on a signal payload and
on editRunState. A step’s output went into the run’s state unchecked, up to
the 1 MiB read from a handler’s response. It is now enforced:
- A step whose output is larger than the cap, measured as the JSON Foliot
stores, fails the step for good with error class
output_too_large, naming the step and both sizes: the run goes to the step’sonFailureif it names one, so a compensation still runs, and otherwise fails. This applies to a synchronous response, to a202completed by token (completeTask) and to a pull worker alike. The step after it does not run, and retrying cannot help, so it is not retried. The history records it as atask.failedevent carrying the error, then astep.exhaustednaming the step it went to, and it is counted infoliot_task_failures_total; the run’s ownerroris overwritten by the next step the run enters, so the history is where the cause is kept. - Behaviour change beyond the cap: a handler that answers with a
nextthat names no step used to end the run asbad_response. It now fails the step in the same way, so a step that declaresonFailureruns that compensation first (a refund, a release) and a step that does not still ends the run. A workflow whose handlers can return a mistypednextshould expect the compensation to run for it. skipStepwith anoutputover the cap is a400, and changes nothing.completeTaskwith anoutputover the cap, or anextthat names no step, is also a400, but it is not a no-op: the step has already failed as above, and the400says so, with the same message the run records. The bad body is what the caller sent, so the caller should see the error where it made it, not a200that reads as accepted while the run fails behind it. The token resolves once, so sending the same body again is a409, not a second failure.- The cap is per step. The run’s accumulated
state, which is sent to every later step, is not capped by steps; its size is measured instead (foliot_run_state_bytes) and a warning is logged when it passes the cap.editRunStatefollows the same rule: each value of itspatchis measured exactly as a step’s output is (the compact JSON of the value alone) and held to the cap, so a patch touching two steps may carry up to the cap for each, and a value over it is a400that names the key and changes nothing. That also means an output the engine stored at the cap can be written back by an edit. The key is bounded separately, to the same number of bytes, because it is stored in the state and recorded in the history: a long key under a small value, or a delete of one, is a400that reports the key’s length and not the key. Neither the whole patch nor the resulting state is checked, so a run whose state has grown well past the cap can still be repaired by editing. - Sizes are the compact JSON of the value, not escaped: a
<,>or&counts as one byte, and a line or paragraph separator (U+2028, U+2029) as three, as in the response you sent.
If a handler returns large data, return a reference to it (a storage key or a URL) and fetch it where it is needed. A run that already holds a larger entry is not touched; only a step that completes from now on is checked.
2026-10-01 — thirteen error statuses the server already sent are now declared.
These were returned and missing from this document, so a generated client had no type for them:
- 503 on
healthand 500 onmetrics. - 409 on
completeTask,failTask,skipStep,signalRunandsignalByBusinessKey. - 400 on
rotateApiKeyandcancelBulkAction(areasonpast 1000 characters), onlistBulkActionsandappAuditTrail(limitout of range), ongetBulkAction(anidthat is not a uuid) and onlistRunEvents.
Regenerate your client to get a typed response for each.
One response changed. GET /health on the TypeScript server answered 500 when
the database did not answer; it now answers the 503 declared here, as the Go
server always has. On both servers the probe is now bounded — two seconds in Go,
and in TypeScript two to connect and two to answer, on a connection of its own: a
database that is unreachable rather than refusing used to leave the request
hanging, and now answers 503. A pool with every connection busy and none handed
out for the whole of that budget answers 503 as well; on TypeScript that check
spends whatever the probe left of the same two seconds rather than a second
allowance, and a pool that is serving answers 200 as soon as it hands one out.
2026-09-29 — the server now enforces the bounds this document has always declared.
maxLength, minLength, maxItems, minimum, enum and pattern on request
bodies were declared here and not checked by the Go server, so it accepted
values this document calls invalid. Those requests now answer 400, which is what
the document, and the TypeScript server, always said. The ones most likely to
affect you:
businessKeyandidempotencyKeypast 200 characters, and more than 20tagsor a tag past 50.- An app
namethat is not lower-case kebab. - A
statusin a bulkfilterthat is not a run status. This one was quiet: the filter matched nothing, so a dry run answeredmatching: 0and read as “that window is already clean” when it meant the status was mistyped. - A duration past the ceiling
Durationnow documents — about 292 years — in any of its three spellings: a unit-suffixed string, a digits-only string, or a JSON number. If you are auditing runs created before this, the symptom differs by spelling:60480000000000wrapped to a run scheduled roughly 163 years out, while the digits-only string"10000000000000"wrapped to a NEGATIVE duration, so the run started immediately for a caller who asked to defer it."100000w"overflowed a float conversion whose result Go leaves to the platform, so it could be either. All are refused now, and the ceiling is in the schema rather than only in this note. reasonpast 1000 characters on the routes that take one.
Nothing in this document changed. If you were relying on the server accepting something it describes as invalid, that is the behaviour that has gone.
Four bounds moved the other way, where the server was stricter than this document and refused requests it calls valid:
reasonon the signing-secret routes and onskipStepnow allows the full 1000 characters, not 500.queueontasks/pollis no longer capped at 100.error.messageontasks/{token}/failno longer has to be non-empty. A handler with nothing to say can now report the failure rather than being refused and leaving the task open until its lease expires.- A bulk
filter.statusmay be a single status as well as a list, which theanyOfhere has always permitted.
And one tightened that is not a string bound: resumeRatePerSecond on the
step-control route has a floor of 0.1, this document’s value. The server
accepted 0.01, a tenth of the slowest release rate described here.
2026-09-29 — two responses corrected. Regenerate any client older than this.
BulkActionJob now uses appId, cancelReason, createdAt and finishedAt.
It was the one response in the API returned straight from the database, so it
carried snake_case while every sibling camelCased. And POST /apps now returns
the full App in its nested app, matching GET and PATCH; it previously
omitted createdAt and updatedAt.
Both are breaking for a generated client, and breaking silently: a missing
JSON key is not a decode error, so a stale client reads an empty appId and a
zero createdAt with nothing failing anywhere.