ADR 0027: Workflow Script Step¶
Date: 2026-11-13
Status¶
Accepted
Related: ADR 0021 (workflows), ADR 0026 (condition step). This ADR is the decision that ADR 0021 asks for before a script step ships.
Context¶
Workflows can wait, ask a model to pick a branch, compare values and
branch on the result (condition), deploy, roll back, send email, and
give a task to an agent. No step transforms data. An author who must
count the failed runs in a webhook payload, reshape a payload for a
later step, or branch on a value that must first be computed, must ask a
model or an agent. That is slow, costs money, and is not deterministic.
A small program does this work best. Running code that people write is a risk: it must not reach the network, the file system, the database, or the credentials of the service that runs it.
pydantic-monty is an interpreter for a subset of Python, written in Rust. It runs code in worker subprocesses. It has no ambient authority: no files, no environment, no network, and no subprocesses unless the host grants them. It enforces limits on memory, run time, and recursion. A spike on 2026-11-13 ran versions 1.5.0 and 1.6.0 on macOS arm64, and 1.6.0 on Linux arm64, and confirmed each of these.
Decision¶
The step type¶
A new step type, script, in the logic category.
| Parameter | Meaning |
|---|---|
inputs |
Names to values. Each name becomes a global of the script. A value can be a reference; one whole reference keeps its type. At most 20. |
code |
Python source, at most 16 KiB. It is never rendered as a template. |
output_fields |
The structured result, in the model of the agent step. At least one. |
branches |
Optional. Port names. When set, the script picks one. |
The value of the last expression of the script is its result. It must be
a mapping that matches the JSON Schema of output_fields. Later steps
reference it as {{ <key>.result.<field> }}.
When branches is set, the result must also hold branch, one of the
names. branch is a reserved field name. The engine removes it before
the schema check and takes the port of that branch. With no branches,
the step has one unnamed port.
Output: result, branch, and stdout (the text of print(), at most
8 KiB).
The step has no side effect, is idempotent, needs no permission, and does not retry by default. Its default timeout is 30 seconds.
No authority¶
The script gets no host functions, no modules from the host, and no mounts. It cannot read a secret, call imbi-api, or make an HTTP request. A step that needs any of these is a different step type with its own decision.
When parameters that name Integration secrets ship (ADR 0021, References), a script input must not take one: a script can copy an input to its result, its output, or an error.
One runner¶
One module of imbi-common, imbi.common.workflows.script_runner,
holds the pool, the limits, the conversion of the result, the schema
check, and the error codes. It is behind the script extra, so
services that do not run scripts do not install pydantic-monty. The
engine and the editor preview both call it, so they have one sandbox
configuration.
- Each call takes one checkout. A worker serves one checkout, then the pool replaces it.
- Limits: 64 MiB of memory and 5 seconds per call by default, never more than the step timeout. The pool kills a worker that does not answer. A sleep returns at once, because the time limit does not count sleeps.
- The result is converted to JSON types. A key that is not a string, a float that is not finite, and a value with no JSON form fail the step. The output is at most 64 KiB of JSON, checked before the schema check and again after the empty optional fields are added.
| Cause | Error | Retry |
|---|---|---|
| Code does not parse | script_syntax_error |
No |
| Code raises | script_error with the type and message |
No |
| Time limit | script_timeout |
No |
| Memory limit | script_memory |
No |
| Worker died | script_crashed |
Yes, when the node sets retries |
| Result not a mapping, not JSON, or not the schema | script_output_invalid |
No |
| Output too large | script_output_too_large |
No |
branch missing or not a branch |
script_branch_invalid |
No |
Template rendering¶
code is Python. Python holds {{ and {% (an f-string, a set in a
dict), so code must not go through the Jinja rendering of ADR 0021.
A step type declares raw_params, the top-level parameters that are
never rendered or checked for references.
Preview¶
The workflow editor can run a script with sample inputs before it saves
the workflow. POST /organizations/{org}/workflows/script-preview takes
code, inputs, output_fields, and branches, and returns the
output of the step or its error. It needs workflow:write. It renders
no references and stores nothing. imbi-api keeps its own small pool for
it. A full pool returns 503.
Consequences¶
- Authors get deterministic transforms and branches without a model.
- The risk is a sandbox escape through a bug in the interpreter. The
worker runs as the same user as its service. In the engine, an escape
reaches the service-account secret and the database. In imbi-api,
where the preview runs, it also reaches the JWT secret and the key
that encrypts Integration credentials. Only members with
workflow:writecan send a script. The alternative, a preview that imbi-api sends to imbi-workflows, keeps scripts out of imbi-api but needs service-to-service auth and a new Helm service. If the risk becomes too high, move the preview there, or run the workers in a separate pod with no credentials. - The Python subset surprises people: no class inheritance, no generators, a different regular expression engine. The admin docs state the subset and link to the vendor's list.
- Each engine process and each imbi-api process keeps a few
montyworker processes. Pod memory limits must allow for them. - pydantic-monty changed its API shape between 0.0.x and 1.x. It is
pinned to
~=1.5, and only the runner imports it. - Step input holds the code, so each step event copies up to 16 KiB of code to ClickHouse.