Skip to main content

Schemas and CEL

Schemas are what make Triggers flows composable instead of guesswork. They tell the runtime and Studio what an action accepts, what it returns, and which bindings are sensible.

Input and result schemas

Every action should advertise both:

  • an input schema for the payload it expects
  • a result schema for the payload it returns

Those contracts matter in three places:

  • handler validation
  • Studio field binding
  • manifest hashing and capability comparison

If the schema is vague or unstable, downstream flow authoring becomes vague or unstable too.

JSON Schema normalization

The repo already has a concrete parity problem the SDKs solve: different languages do not naturally emit identical JSON Schema documents for the same logical type.

The Python SDK normalizes Pydantic output so it matches Go conventions by:

  • inlining $ref and $defs
  • converting nullable anyOf shapes into the Go-style type: ["T", "null"]
  • preserving object required fields
  • preserving additionalProperties for maps

That normalization is not polish. It keeps manifest hashes stable across SDK languages for equivalent action contracts.

result_event is schema-adjacent metadata

result_event is not the action result itself, but it depends on the same discipline. It gives Studio the public result shape needed for downstream field binding. In chained flows, weak output metadata is effectively a build-time usability bug.

The important limit is that v2 result_event declarations are catalog metadata. They help downstream binding, but they are excluded from the manifest hash and they do not replace the runtime result payload.

Schema lineage and field names in CEL

Schemas follow data edges. They do not always come from the source event:

Node outputSchema supplied to the next data node
SourceThe source schema.
Durable Timer or serviceThe direct input schema, unchanged.
Actionresult_event.fields declared by the action manifest.
MappingThe mapping projection.
ConditionThe direct upstream schema, unchanged.

Retry is control-only and is not part of data-schema lineage. It can rearm a Durable Timer, but it does not make an action result replace the Timer's saved event.

Conditions, mappings, and action input overrides evaluate against the schema of their direct upstream data node. Each available schema field is exposed two ways:

  • bare name: bet_amount
  • payload-scoped: payload.bet_amount

A CEL literal is never rooted at payload. — it is left exactly as written. That covers a quoted string, a number in any base (including a leading-dot float like .5), a raw or bytes string (r"x", R"x"), the true / false / null keywords, and a map literal. This applies to both a mapping output and a lookup node's param, which compile through the same evaluator. Earlier, the rule only checked for operator characters, so a bare literal such as "web" was rewritten to payload."web": it compiled fine at release and then failed on every event at runtime.

On release, Notify writes the resolved direct-upstream fields to immutable config.input_fields for every Condition, Mapping, and Action. Studio, release validation, and the Go and Python SDKs use that same pinned list. A missing list is supported only for releases created before this contract; new releases must carry it. Unknown identifiers still block.

CEL limits

CEL shows up when flows evaluate expressions for routing or transformation. The runtime needs those expressions to be safe and bounded, so the SDK environment docs call out a few guardrails:

  • TRIGGERS_CEL_COST_LIMIT
  • TRIGGERS_CEL_EVAL_DEADLINE_MS
  • TRIGGERS_CEL_PROGRAM_CACHE

Those are practical controls, not theoretical ones. They keep expression evaluation from becoming an unbounded CPU or latency surprise in a busy service.

Payload guardrails

Two other limits shape what schemas and CEL will end up handling in practice:

  • TRIGGERS_MAX_PAYLOAD_BYTES
  • TRIGGERS_MAX_PAYLOAD_DEPTH

If an inbound payload blows past those limits, the issue is not a fancy binding problem. It is a contract boundary problem and should be treated that way.

When you are designing an action contract:

  • keep schemas explicit
  • keep field names stable
  • think about downstream binding before you need it
  • do not rely on observability events to carry output data
  • treat CEL and payload limits as part of the runtime contract, not as optional tuning trivia