7.3 Step kinds: call, transform, pipeline, split

Each step does exactly one of four things — call, transform, pipeline, or split — plus optional modifiers (if, try, as/name, retry). This section covers what each kind does to the in-flight message.

The in-flight message is the value flowing through the pipeline. A step usually replaces it with its result; as and try change that, as noted below.

call — invoke another mount, or an external URL

The in-flight message is sent to the (interpolated) URL through full dispatch. GET/HEAD send no body; other methods forward the in-flight body. The response becomes the new in-flight message.

{ "call": { "method": "GET", "url": "/data/orders/${id}" } }

An absolute http(s):// URL leaves the node through the mount's httpOut grants (8.5) — the same allowlist-grant vocabulary code mounts use, declared on the pipeline (or wrapper) mount's config:

{ "path": "/orders", "service": "pipeline", "config": {
    "access": { "invoke": "U", "write": "A" },
    "grants": { "stripe": { "type": "httpOut",
                            "hosts": ["api.stripe.com"],
                            "inject": "infra:stripe-key" } } } }
{ "call": { "method": "POST", "url": "https://api.stripe.com/v1/charges",
            "headers": { "x-account": "${account.id}" },
            "effect": "keyed" } }
  • Allowlist first: the target host must match a grant's hosts (exact or *.suffix); no match is capability_denied before any I/O. A mount with no httpOut grants cannot call out at all. The check runs after ${...} interpolation, so a variable-built URL is gated the same way.
  • Credentials are injected host-side: the matching grant's inject (operator infra: ref or an inline strategy over granted secrets) is applied on the way out — the secret never appears in spec content. With overlapping grants, the first matching grant in grant-name order wins.
  • The external request is built fresh: it carries only the step's declared headers (plus the auto idempotency key, 7.7) — never the inbound caller's Authorization/Cookie, and never the principal. elevate is inert on an external call. Header values interpolate with the same ${...} rules as the URL. When absent, the host derives Content-Type from a forwarded body and supplies Accept: */* plus an rs2/<version> User-Agent.
  • Retry applies unchanged (7.7): retryable statuses, network errors, backoff, Retry-After, and effect-class gating work exactly as for internal calls. Transport failures surface as a 502-class step failure with the usual per-step report.
  • GET <spec>?$plan warns about literal external URLs no grant covers (7.8).

transform — reshape the body with JSONata

A JSONata expression (or a template object whose string leaves are expressions) computes a new body from the current one. Covered in detail in 7.6.

{ "transform": { "id": "$order.id", "ok": "$_ok" } }

pipeline — a nested subpipeline

A nested spec runs as a step. Its end/stop actions are local to it. Nesting is how you build parallel branches and scoped sub-flows (7.4).

{ "pipeline": { "mode": "parallel", "steps": [ … ], "join": "jsonObject" } }

split — fan out over a collection (jsonSplit)

An array or object body is split into one message per element; the remaining steps of the pipeline run per element, in parallel (fan-out cap 1000, concurrency default 12), then the results join (jsonObject, keyed by element name/index; errored elements become {"_errorStatus", …}).

{ "split": "jsonSplit" }

The modifiers

  • as: "$var" — capture the step's result into a variable instead of replacing the in-flight message. The message keeps its prior body; a failed captured call becomes {"_errorStatus", "_errorMessage"} in the variable and the pipeline continues. Use it to gather data without losing your working message.
  • try: true — a failure becomes the body {"_errorStatus", "_errorMessage"} with status 200 and the pipeline continues, instead of failing the step. Use it for "attempt, but don't abort on error."
  • if: "<condition>" — run the step only if the condition holds (7.5). A skipped step silently does nothing.
  • name / retry — name a step (for parallel joins) and attach a retry policy (7.7).
  • elevate: true (call steps only) — add the mount's configured mediation role to the internal call while retaining the caller's identity. See below.

elevate — the gateway pattern

By default a call step forwards the caller's identity, so the internal call is re-checked against the target mount's roles (5.4): a caller can only reach, through a pipeline, what they could reach directly. elevate: true adds the pipeline mount's configured elevate role to that principal for this call. An anonymous caller gets a synthetic principal carrying only that mediation role.

{ "call": { "method": "GET", "url": "/data/secrets/${id}" }, "elevate": true }

This restores v1's gateway pattern: lock a service down, then front it with a caller-accessible pipeline that elevates — so non-admins get mediated access (validated, transformed, or restricted by the pipeline) without direct access to the locked mount.

// /data/payroll  — readable only with the `payroll` role
{ "path": "/data/payroll", "service": "data",
  "config": { "access": { "read": "payroll" } } }

// /my-pay  — a pipeline mount configured (by an operator) with
//            { "elevate": "payroll" }; anyone signed in may run it
{ "if": "...caller owns this record...",
  "call": { "url": "/data/payroll/${principalId}" }, "elevate": true }

Safe by design: the elevate step adds the mount's operator-configured elevate role to the caller's principal (keeping their identity) — it does not drop identity or grant blanket trust. The authority lives in the mount config, not the spec flag: without a configured role the flag is inert, and an elevate role may not be an operator role (5.0). Omit it and behavior is unchanged (principal forwarded and re-checked). In the string DSL it's a leading elevate token on the step.

${...} interpolation

In a call URL, ${...} resolves against, in order: captured variables, then the input body's JSON fields, then query params. Dot-paths work (${order.customerId}). An unresolvable placeholder is a 400 — interpolation never silently produces an empty string.

The same interpolation applies to a call's declared header values. With the step vocabulary in hand, the next section covers the modes that orchestrate them.


Previous: 7.2 Typed spec and DSL · Manual home · Next: 7.4 Modes →