Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

What is RuleFlow

RuleFlow is a platform for modeling, governing, executing and auditing decisions and workflows in one product. It replaces the fragmented setup most enterprises end up with: a rules engine, a separate workflow/BPM engine, custom authentication and gateway glue, and hand-rolled audit logging — four or five systems wired together, none of them sharing a model or a version history.

RuleFlow — where decisions become workflows.

The shape of the product

A RuleFlow project holds:

  • Decisions — decision tables, expressions and switches that turn input data into a typed output (a risk tier, an approval, a price).
  • Workflows — a graph of steps (decision calls, human tasks, service calls, gateways, waits) that orchestrates a process end to end.
  • Releases — immutable, versioned snapshots of a project’s decisions and workflows, diffable and deployable to an environment.
  • Environments — named deployment targets (dev, staging, prod); promoting to a protected environment requires an approver role.
  • Audit — every write (create, update, delete, deploy, rollback, task completion) is recorded with actor, action and detail.

Everything above is scoped to a tenant: every API call acts only as the tenant in the caller’s verified token, and cross-tenant access is indistinguishable from “not found” (see Tenancy & isolation).

How a decision runs

A decision is authored once, as JSON (studio users never see this directly), and evaluated by the same engine everywhere: in a browser via WASM for instant feedback while authoring, over HTTP for ad-hoc simulation, and in-process inside an AWS Lambda when a deployed workflow calls it. Same binary, same semantics, three surfaces — a decision that passes its tests in the studio behaves identically in production.

Decision inputs and outputs are typed against a schema; the logic inside a decision is written in a small, deterministic expression language — not a general-purpose scripting language — so a stored rule can be replayed forever and always produce the same answer.

How a workflow runs

A workflow compiles to an AWS Step Functions state machine. Authors never write Step Functions JSON directly; the compiler is the only thing that touches it. Each step type maps to a concrete execution mechanism:

Step typeExecutes as
decision_taskin-process call into the Rust decision engine (Lambda)
service_taska connector call via a queued, task-token callback
human_taska paused execution waiting on a task token, resumed by an API call
gatewaya branch on prior decision/task output
wait / endtimer / terminal state

Decision and task results accumulate into the workflow’s state under reserved namespaces ($.decisions.<step>, $.tasks.<step>), so a later step can read any prior step’s output. See Workflows for the full model.

Who uses it

  • Business analysts author and update decision tables and workflow logic without waiting on a code release.
  • Backend engineers integrate RuleFlow via the control-plane API — starting executions, resuming human tasks, wiring service-task connectors.
  • Compliance/auditors review who changed what, which version ran, what the trace was, and whether the required approval happened.
  • Operations/SRE monitor executions, retries and failures.

Where to go next

  • New to the platform? Start with the quickstart — model and simulate a decision in a few minutes.
  • Building an integration? Jump to the API reference.
  • Evaluating for a regulated environment? Read Security.

Quickstart: your first decision

This walks through modeling a small decision — a loan risk tiering rule — and simulating it, without deploying anything. It uses the same shape as the loan-approval example project.

The model

A decision has typed inputs, typed outputs, and a graph of nodes. The simplest useful node is a decision_table: a set of rules, each row a condition per input column and a value per output.

{
  "id": "loan_risk",
  "name": "Loan risk tiering",
  "inputs": [
    { "name": "credit_score", "type": { "kind": "integer" }, "required": true },
    { "name": "debt_to_income", "type": { "kind": "number" }, "required": true }
  ],
  "outputs": [{ "name": "risk_tier", "type": { "kind": "string" } }],
  "nodes": [
    {
      "type": "decision_table",
      "id": "risk",
      "hit_policy": "first",
      "inputs": [{ "expr": "credit_score" }, { "expr": "debt_to_income" }],
      "outputs": [{ "name": "risk_tier" }],
      "rules": [
        { "id": "prime",      "when": ["[750..850]", "<= 0.35"], "then": [{ "value": "LOW" }] },
        { "id": "near_prime", "when": [">= 680",     "<= 0.45"], "then": [{ "value": "MEDIUM" }] },
        { "id": "subprime",   "when": ["-", "-"],                "then": [{ "value": "HIGH" }] }
      ]
    },
    {
      "type": "output",
      "id": "out",
      "bindings": [{ "output": "risk_tier", "value": { "expr": "risk_tier" } }]
    }
  ]
}

A few things worth noticing:

  • hit_policy: "first" means the first matching rule wins, evaluated top to bottom.
  • [750..850] is an inclusive range; <= 0.45 is a comparator; - is a wildcard that always matches. See Expressions § decision-table cells for the full predicate syntax.
  • The output node binds the table’s result to the decision’s declared output.

Simulate it

Simulation runs a decision against one set of inputs without storing anything — useful while authoring. Against a saved project artifact:

curl -sS -X POST \
  "$RULEFLOW_API/api/projects/lending/decisions/<artifactId>/simulate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "credit_score": 780, "debt_to_income": 0.2 }'
{ "risk_tier": "LOW" }

Or ad hoc, against a decision that has not been saved yet, via the engine proxy:

curl -sS -X POST "$RULEFLOW_API/api/engine/simulate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "decision": <the decision JSON above>, "inputs": { "credit_score": 780, "debt_to_income": 0.2 } }'

Both paths run the identical engine — what you see in simulate is exactly what a deployed workflow’s decision_task produces.

Save it as an artifact

curl -sS -X POST "$RULEFLOW_API/api/projects/lending/decisions" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "loan_risk", "model": <the decision JSON above> }'
{ "id": "art_9f2c...", "tenant": "acme", "project": "lending", "kind": "decision", "name": "loan_risk", "model": { "...": "..." } }

The response is the stored Artifactid is what you reference from GET/PUT/DELETE .../decisions/{artifactId} and from a workflow’s decision_task. Full request/response shapes are in Projects & artifacts.

Next

Chain this decision into a workflow: Your first workflow.

Your first workflow

A workflow orchestrates a process by chaining steps: decisions, gateways, human tasks, and service tasks. This continues the loan example: score risk, price the loan, branch on approval, get underwriter sign-off, disburse funds.

The model

{
  "id": "loan_application",
  "name": "Loan application",
  "start": "score",
  "steps": [
    { "type": "decision_task", "id": "score", "decision": "loan_risk",    "next": "price" },
    { "type": "decision_task", "id": "price", "decision": "loan_pricing", "next": "gate" },
    {
      "type": "gateway",
      "id": "gate",
      "branches": [{ "variable": "approved", "op": "eq", "value": true, "next": "review" }],
      "default": "reject"
    },
    { "type": "human_task", "id": "review", "assignee": "underwriter", "next": "disburse" },
    {
      "type": "service_task",
      "id": "disburse",
      "action": "disburse-funds",
      "next": "done",
      "retry": [{ "errors": ["States.TaskFailed"], "max_attempts": 3, "interval_seconds": 5, "backoff_rate": 2.0 }],
      "catch": [{ "errors": ["States.ALL"], "next": "reject" }]
    },
    { "type": "end", "id": "done" },
    { "type": "end", "id": "reject" }
  ]
}

Step types used here:

  • decision_task — calls a named decision (must exist in the same project/release) with the whole workflow state as input; its outputs land under $.decisions.<stepId>.
  • gateway — branches on a prior step’s output. gate reads $.decisions.price.approved (the loan_pricing decision’s approved output) via the variable field.
  • human_task — pauses the execution until a person completes it via the API (see Executions); assignee is stored but authorization also accepts an approver/admin role.
  • service_task — calls out to an external system through a connector; retry/catch follow Step Functions’ Retry/Catch shape.
  • end — a terminal state; a workflow can have several (here: done, reject).

How state flows

Each decision_task/service_task/human_task result is written under a per-step namespace instead of replacing the state:

$.decisions.<stepId>   # decision_task output
$.tasks.<stepId>       # service_task / human_task result

So after score runs, state looks like:

{ "credit_score": 780, "debt_to_income": 0.2,
  "decisions": { "score": { "risk_tier": "LOW" } } }

price still receives the entire state, so it can read risk_tier from $.decisions.score even though its own declared inputs are named differently at the top level — the compiled ASL passes the whole object through. Because results accumulate rather than replace, a decision downstream never clobbers an upstream one, and a gateway can always branch on $.decisions.<step>.<field>.

decisions and tasks are reserved top-level keys in workflow/decision state. Don’t name an input schema field decisions or tasks — see Workflows § reserved namespaces.

Compile it

Compiling turns the model into an AWS Step Functions definition. This is a read-only, non-persisting operation useful to sanity-check a workflow before saving it:

curl -sS -X POST "$RULEFLOW_API/api/engine/compile-workflow" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @loan_application.json

The response is Amazon States Language JSON — an internal detail; authors never hand-write or hand-edit it. It still contains unresolved placeholders (like ${RuleFlowEngineArn}) that only get filled in at deploy time (see Release, deploy, execute) — compiling alone does not produce a runnable machine.

Next

Save the workflow, cut a release, deploy it, and run it: Release, deploy, execute.

Release, deploy, execute

Decisions and workflows are edited freely as artifacts, but nothing runs in an environment until it is cut into a release and deployed. This is the governance boundary: a release is an immutable, versioned snapshot, and a deploy is an explicit, audited promotion of one release to one environment.

1. Save your artifacts

Both the loan_risk/loan_pricing decisions and the loan_application workflow from the previous pages need to exist as saved artifacts in the same project first (POST /api/projects/{project}/decisions and .../workflows — see Projects & artifacts).

2. Cut a release

A release snapshots every decision and workflow artifact currently in the project:

curl -sS -X POST "$RULEFLOW_API/api/projects/lending/releases" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "notes": "initial loan-approval flow" }'
{
  "id": "rel_4a1c...", "tenant": "acme", "project": "lending", "version": 1,
  "notes": "initial loan-approval flow", "createdBy": "alice@acme.com",
  "createdAt": "2026-07-21T18:00:00Z",
  "items": [
    { "kind": "decision", "name": "loan_risk",    "model": { "...": "..." } },
    { "kind": "decision", "name": "loan_pricing", "model": { "...": "..." } },
    { "kind": "workflow", "name": "loan_application", "model": { "...": "..." } }
  ]
}

A release is immutable once created — editing artifacts afterward never changes a past release. Two releases can be compared with GET /projects/{project}/releases/{releaseId}/diff/{otherId} (semantic diff, down to the changed table rule).

3. Deploy it to an environment

curl -sS -X POST "$RULEFLOW_API/api/projects/lending/environments/dev/deploy" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "releaseId": "rel_4a1c..." }'
{ "environment": "dev", "releaseId": "rel_4a1c...", "version": 1, "updatedBy": "alice@acme.com" }

Deploying to staging or prod requires the approver (or admin) realm role — dev does not. On deploy, every workflow artifact in the release is compiled and substituted into a real AWS Step Functions state machine: placeholders like the engine ARN are resolved, decision models are inlined verbatim from the release (no runtime fetch — the machine is a self-contained snapshot of that exact release), and default timeouts are injected where the author set none. In-flight executions of a prior deploy are unaffected — Step Functions snapshots a machine’s definition at start, so only future executions pick up the new one.

Roll back with the equivalent .../environments/{env}/rollback call, gated the same way for protected environments.

4. Start an execution

curl -sS -X POST "$RULEFLOW_API/api/projects/lending/executions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: loan-app-2026-07-21-0001" \
  -d '{ "workflow": "loan_application", "env": "dev",
        "input": { "credit_score": 780, "annual_income": 80000, "debt_to_income": 0.2, "requested_amount": 10000 } }'
{ "id": "exec_88df...", "workflow": "loan_application", "env": "dev", "status": "RUNNING", "startedAt": "2026-07-21T18:05:00Z" }

Send the same Idempotency-Key again with the same request and you re-attach to the original execution instead of starting a second one — see Executions § idempotency. Starting an execution also reserves a monthly quota slot and records metered usage (workflow_execution) — see Usage & quotas.

5. Watch it, complete the human task

curl -sS "$RULEFLOW_API/api/projects/lending/executions/exec_88df..." \
  -H "Authorization: Bearer $TOKEN"

The response merges the stored execution record with a live status/history pull from Step Functions. Once the workflow reaches review, the execution pauses; list and complete the pending human task:

curl -sS "$RULEFLOW_API/api/projects/lending/executions/exec_88df.../tasks" \
  -H "Authorization: Bearer $TOKEN"

curl -sS -X POST \
  "$RULEFLOW_API/api/projects/lending/executions/exec_88df.../tasks/review/complete" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "output": { "signedOffBy": "underwriter-42" } }'

Completing resumes the paused execution; disburse (the service_task) then runs through its bound connector. See Workflows and Executions for the full lifecycle, retry/catch semantics, and failure taxonomy.

Decisions

A decision is a pure function: typed inputs in, typed outputs out, evaluated by a graph of nodes. It has no side effects, no IO, and — given the same inputs and the same stored model — always produces the same result. That determinism is what makes decisions safe to version, audit, replay and run identically in a browser (WASM), over HTTP, and inside a Lambda.

Shape

Decision
├── inputs        # typed, required/optional
├── outputs       # typed
├── nodes
│   ├── input
│   ├── decision_table
│   ├── expression
│   ├── switch
│   └── output
└── tests         # given/expect cases, run on every change

Node types

  • decision_table — a set of rules; each rule pairs a condition per input column with a value per output. hit_policy controls how multiple matching rules resolve ("first" — first match wins, evaluated top to bottom, is the common case). Cell conditions use a compact predicate syntax — ranges, comparators, membership, wildcards — documented in Expressions.
  • expression — computes one output from an expression over the decision’s inputs (and other nodes’ outputs).
  • switch — maps a value to one of several cases (when/then pairs) with a default fallback; simpler than a table when you’re branching on one value rather than combining several.
  • output — binds computed values to the decision’s declared outputs. Every decision ends in one.

Nodes can reference each other’s results by id, so a decision is a small DAG: a table can feed an expression, an expression can feed a switch, and so on, ending at the output node.

Tests

Every decision carries its own tests: given inputs and expected outputs, checked on every change. A project can additionally define test_suites — named groups of cases against a specific decision, useful for broader regression coverage than the inline per-decision tests. Both run through the same engine as production evaluation, so a green test is a real guarantee, not a mock.

{
  "name": "prime borrower is low risk",
  "given": { "credit_score": 780, "debt_to_income": 0.2 },
  "expect": { "risk_tier": "LOW" }
}

Storage and simulation

A decision is created/edited as an artifact (kind: "decision") scoped to a project — see Projects & artifacts. Two ways to run one without deploying anything:

  • Simulate a saved decision: POST /projects/{project}/decisions/{artifactId}/simulate with the input object as the body.
  • Simulate ad hoc: POST /engine/simulate with { decision, inputs } — useful while iterating on a model that isn’t saved yet.

Both paths call the exact same engine entry point a deployed workflow’s decision_task uses (ruleflow_engine::simulate), so simulation results are never an approximation of production behavior.

Versioning

A decision artifact is mutable while you iterate; a release freezes its current model into an immutable snapshot. Deployed workflows always run the decision model inlined at deploy time from the release that was deployed — never the live, possibly-since-edited artifact — so a running environment’s behavior never drifts out from under you.

Expressions

RuleFlow’s expression language is the small, total, side-effect-free language used inside decision_table cells, expression nodes, switch cases and output bindings. It is a bespoke safe subset, not an embedded general-purpose interpreter (not Rhai, not Lua, not JS) — deliberately, for two reasons:

  • Determinism / auditability. A decision must replay to the identical result forever, and produce the same answer in the browser (WASM) and on the server. That rules out clocks, randomness and IO.
  • Stability. Once a rule references an operator or function, changing or removing it can silently alter a customer’s logic. The surface is small, explicit and versioned alongside the model.

Guarantees

  • Total & pure. No IO, clock or randomness. No loops, no recursion, no user-defined functions — the only callable surface is the fixed function library below. Evaluation always terminates.
  • Decimal arithmetic. Numbers are exact decimals, never f64 — financial precision by construction, not by convention.
  • Cross-target parity. The same evaluator compiles to native and to WebAssembly, so a browser preview and a server run evaluate identically.

Types

null, bool, number (decimal), string, date, array, object. Literals: numbers (42, 0.35), double-quoted strings with escapes (\" \\ \n \t), true, false, null, array literals ([a, b, c]). There is no date or object literal — both enter only via input data.

Grammar

Lowest to highest precedence:

or      → and  ( "or"  and )*
and     → eq   ( "and" eq  )*
eq      → cmp  ( ( "=" | "!=" ) cmp )*
cmp     → add  ( ( "<" | "<=" | ">" | ">=" | "in" ) add )*
add     → mul  ( ( "+" | "-" ) mul )*
mul     → unary( ( "*" | "/" | "%" ) unary )*
unary   → ( "-" | "not" ) unary | postfix
postfix → primary ( "." IDENT | "[" or "]" )*
primary → NUMBER | STRING | "true" | "false" | "null"
        | "[" args "]" | IDENT | IDENT "(" args ")" | "(" or ")"

a.b reads an object field; a[i] indexes an array or string by an integer.

Operators

  • Arithmetic + - * / % on numbers. + also concatenates two strings ("a" + "b""ab"); mixing a number and a string is an error.
  • Equality = / != is type-aware and numeric-tolerant: 1 = 1.0 is true. (There is no === alone is equality.)
  • Ordering < <= > >= compares numbers, dates and strings (lexicographically); comparing incomparable types is an error, not a silent false.
  • Membership x in [a, b, c] is true when x equals any element.
  • Logical and / or short-circuit; not negates a boolean. A non-boolean operand to a logical operator is an error.

Functions

FunctionArityNotes
if(cond, a, b)3lazy — only the taken branch is evaluated
min(…), max(…)≥1 numericvariadic
abs(n)1 numeric
round(n), floor(n), ceil(n)1 numeric
len(x)1string (chars), array or object length
contains(hay, needle)2substring (string) or element membership (array)
startsWith(s, prefix)2strings
lower(s), upper(s)1strings
coalesce(…)≥0first non-null argument, else null
dateDiffDays(a, b)2 datesa - b in whole days
isBefore(a, b), isAfter(a, b)2 dates

Calling an unknown function, the wrong arity, or the wrong argument type is a typed error surfaced to the author — never a silent default.

Decision-table cell conditions

Table cells use a compact predicate mini-language, one per input column, tested against that column’s value:

FormMeaning
- (or empty)wildcard, always matches
>= 18, < 100, != 0, = "GOLD"comparison / (in)equality
[18..65], (0..100], [1..10)range; [/] inclusive, (/) exclusive
in ["A", "B"]set membership
bare expression ("GOLD", 5, tier)equality against the column value

The right-hand side of a cell is a full expression, so it may reference other inputs — e.g. <= creditLimit in one column reading a value computed in another.

Where it’s used

expression nodes, switch case when predicates, output bindings ({ "expr": … }), and decision-table cells all share one parser and evaluator — semantics are identical everywhere the language appears.

Versioning

The expression language version is coupled to the project’s model_version — there is no separate version number. Additive changes (a new pure function, a new operator) are a minor, backward-compatible bump. Removing a function/operator, or changing evaluation semantics, is a breaking, major bump. A stored release pins a model_version, and the engine refuses to run a model whose version line it does not implement.

Workflows

A workflow orchestrates a process as a graph of steps. Authors model it in RuleFlow’s own step vocabulary; RuleFlow compiles it to an AWS Step Functions state machine at deploy time. Step Functions (Standard, not Express) is an implementation detail — you never author or read ASL directly, though POST /engine/compile-workflow will show it to you if you want to look.

Shape

Workflow
├── start
├── steps
│   ├── service_task
│   ├── human_task
│   ├── decision_task
│   ├── wait
│   ├── gateway
│   └── end
├── events
├── timers
└── error_handlers

Step types

  • decision_task — calls a named decision from the same project/release. Executes as an in-process call into a Rust Lambda that links the decision engine directly (no network hop, deterministic, scales to zero) — the same engine entry point /v1/simulate uses, so browser/server/runtime stay in parity. The whole workflow state is passed as input; the engine simply ignores fields outside the decision’s declared inputs, which is what lets a downstream decision read an upstream one’s result.
  • service_task — calls out to an external system through a connector, resolved by a logical action name bound per-tenant to a connector type + config. Executes via a queued task-token callback: the compiled step sends a message to a work queue and waits; a connector worker performs the call and reports success/failure back.
  • human_task — pauses the execution on a task-token callback until a person completes it via the API. Given no timeout by default (human steps are long-lived) — a token lives up to the Step Functions maximum (one year) before it expires.
  • gateway — branches on a prior step’s output (variable/op/value against a $.decisions.…/$.tasks.… path), with a default branch.
  • wait — a timer delay.
  • end — a terminal state; a workflow can declare several.

retry/catch on a step follow Step Functions’ Retry/Catch shape directly (errors, max_attempts/interval_seconds/backoff_rate for retry; errors/next for catch) — authored, passed through by the compiler verbatim.

Reserved state namespaces

The runtime has exactly one shared mutable object per execution: the state that flows step to step. Let input be what was passed to POST .../executions. The invariant:

state = input ⊎ { "decisions": { <stepId>: <outputs> } } ⊎ { "tasks": { <stepId>: <result> } }
  • $.decisions.<stepId> — the outputs object of each decision_task.
  • $.tasks.<stepId> — the result of each service_task/human_task.
  • Everything else under $ is the caller’s original input, never mutated.

decisions and tasks are therefore reserved top-level keys — don’t declare a schema field with either name; a decision/workflow whose input shape uses one would get silently overwritten by the next step’s result.

Results accumulate, they never replace: after a decision_task named score runs, state gains a decisions.score key alongside everything that was already there. A gateway or a later decision_task reads any prior step’s result from that namespace.

Compiling and running

Authoring and compiling are pure and read-only — POST /engine/compile-workflow never touches anything running. A workflow only becomes a runnable machine at deploy time, when the control plane resolves placeholders (engine ARN, inlined decision models, connector queues, default timeouts) against the concrete release being deployed. See Releases, environments & rollback for the deploy lifecycle and Executions for starting and observing runs.

Failure model

Three error classes, deliberately handled differently:

  1. Deterministic decision errors (bad input shape, an expression that can’t evaluate) are reproducible — the same (model, input) fails identically every time. These fail fast; retrying is wasted effort. An author-declared catch on the step is the way to handle them.
  2. Transient AWS faults (throttling, a cold-start hiccup) are not reproducible and should be retried with backoff — declare a retry on the step for this class.
  3. Deploy-time errors (an unresolved decision name, malformed compiled ASL) fail the deploy, never a run — the earliest point they can be caught, and the reason decision models are inlined rather than fetched at runtime.

Releases, environments & rollback

Artifacts (decisions, workflows) are mutable while you iterate. A release is what turns them into something governable: an immutable, versioned snapshot you can diff, deploy, and roll back to.

Releases are immutable snapshots

POST /projects/{project}/releases snapshots every decision and workflow artifact currently in the project into one versioned bundle (version auto-increments per project). Nothing about a release changes after creation — editing an artifact afterward never mutates a past release. This is what makes “which version ran” an answerable, audit-safe question.

Two releases can be compared:

GET /projects/{project}/releases/{releaseId}/diff/{otherId}

The diff is semantic, not textual — it surfaces down to the changed decision-table rule, not a JSON line diff.

Environments and deploy

An environment (dev, staging, prod, or any name you choose) tracks which release is currently active. Deploying is explicit and audited:

POST /projects/{project}/environments/{env}/deploy
Body: { "releaseId": "<id>" }

staging and prod (and production) are protected: deploying to them requires the approver or admin realm role. Any other environment name does not require an elevated role.

On deploy, every workflow artifact in the release is compiled and substituted into an AWS Step Functions state machine before the deployment pointer is recorded — a substitution/creation failure aborts without flipping the active release. The state machine embeds the release’s exact decision models: there is no runtime fetch and therefore no drift — a deployed workflow runs the decisions it was released with, forever, until a new release is deployed over it.

In-flight executions are unaffected by a redeploy. Step Functions snapshots a machine’s definition at StartExecution; updating the machine (including a rollback) changes only future executions.

Rollback

POST /projects/{project}/environments/{env}/rollback

Re-runs substitution for the prior release’s workflows and points the environment back at it. Protected environments require the same approver/admin role as a forward deploy.

Audit

Every governance action — artifact create/update/delete, release created, env deployed, task completed/rejected — is appended to the tenant’s audit log with actor, action and a human-readable detail string. See Governance & audit.

Governance & audit

Every write the control plane performs — artifact changes, releases, deploys, rollbacks, task completions, tenant provisioning — is recorded as an audit entry: actor, action, and a detail string, scoped to the tenant (and usually the project).

What gets audited

Representative action names already in use: release.created, env.deployed, execution.started, task.completed, task.rejected, decision.deleted, workflow.deleted, tenant.provisioned, tenant.updated. Audit writes are best-effort — an audit-append failure never blocks or fails the operation it’s recording, the same posture usage metering takes. This trades a theoretical gap in the audit trail for never letting an observability write become an availability problem.

The actor is the caller’s email (falling back to the token subject if no email claim is present).

Reading audit

Two surfaces, both tenant-scoped:

  • Per-project: GET /projects/{project}/audit — the simple list.
  • Tenant-wide, filterable: GET /audit?project=&action=&actor=&from=&to=&limit= — spans every project when project is omitted. Pairs with GET /audit/summary for aggregate counts (by action, actor, day) over the same filters — built for building a compliance report without pulling every row client-side.

See Audit reference in the API section for exact request/response shapes (audit shares the artifacts/releases API page since it’s exposed alongside project governance routes).

RBAC

Within a tenant, realm roles gate specific actions:

  • Deploying to, or rolling back, a protected environment (staging, prod/production) requires approver or admin.
  • Completing a human task requires being the task’s assignee, or holding approver/admin, when the task has an assignee set.
  • Admin-only, cross-tenant reporting routes (/admin/usage, /admin/tenants) require admin.

Roles are orthogonal to tenancy: a role never widens the tenant scope. An admin acting on their own tenant’s releases still only sees that tenant’s data — role checks gate what you may do, tenancy gates whose data you can do it to. See Tenancy & isolation.

What governance guarantees, concretely

  • Reproducibility — a release is immutable, so “what ran in prod on this date” is always answerable by looking up the deployment’s release_id.
  • Traceability — every governance action has an actor and a timestamp.
  • Separation of duties — protected-environment deploys require a different role than the one that authors decisions, when your realm setup assigns them separately.
  • Non-drift — a deployed workflow’s decision logic is frozen at deploy time (inlined from the release), so it cannot silently change underneath a running environment.

Tenancy & isolation

RuleFlow is multi-tenant by default. Every control-plane operation is scoped to the caller’s verified tenant claim — there is no request in the API where tenant is inferred from a URL path, a query string, or a body field.

The whole model reduces to one sentence: every operation acts only as the tenant in the verified token. Its strength depends on exactly two things: the token cannot lie about the tenant, and no code path can forget the filter. Both are structurally enforced — see below.

The tenant boundary is the token, never the request

A request with no tenant claim (or an empty one) is rejected 403 on every tenant-scoped route — there is no default tenant and no god-tenant. A caller cannot ask for another tenant’s data by changing a path parameter; the only tenant they can act as is the one in their token.

project is a label within a tenant, taken from the path — not a security boundary. There is no isolation between two projects of the same tenant; any of that tenant’s members can read any of its projects. Two tenants may both use project=lending without collision (the tenant is the partition).

Three enforcement layers

  1. Identity (Keycloak). The tenant claim’s provenance: admin-provisioned for direct users, hardcoded per-IdP at SAML brokering onboarding time for federated users (the SAML assertion’s own tenant attribute is ignored — an IdP cannot assert a tenant other than the one it was onboarded for). Service-account (machine) tokens carry no tenant claim at all, and are therefore rejected on every tenant-scoped route by construction.
  2. Contract (the Store). Every store method takes tenant as a required parameter — the persistence interface documents “all methods are scoped to a tenant,” and both the Postgres and in-memory implementations filter by it. No method exists that reads across tenants: a handler physically cannot express a cross-tenant query.
  3. Infrastructure. Tenant is the leading dimension everywhere data rests — the DynamoDB partition key is TENANT#<t>#PROJ#<p> for executions/human tasks/connector bindings, search queries carry a mandatory tenant filter, and relational indexes lead with tenant.

Cross-tenant access is “not found,” not “forbidden”

Fetching another tenant’s artifact or release by id returns a typed not-found error → HTTP 404, the same response whether the id doesn’t exist at all or belongs to someone else. This means the existence of another tenant’s objects is never revealed to a caller who doesn’t have access to them.

RBAC is orthogonal to tenancy

Realm roles (approver, admin, …) gate what an authenticated caller may do; tenancy gates whose data they can do it to. A role never widens tenant scope — a privileged deploy still resolves the release within the caller’s own tenant.

Isolation tiers

TierWhat is sharedStatus
PooledShared services + database; row/partition-level scoping by tenant claimThe only tier that runs today; default for all tenants
BridgeShared services; per-tenant schema/databaseTooling exists; no app-level routing yet
SiloDedicated stack per tenantDeployment tooling exists; nothing deployed yet

Because authorization is always “act as the token’s tenant,” moving a specific tenant from pooled to bridge/silo is a data-placement change, not an authorization-model change — the API surface and authored rules are unaffected either way. Pooled is the only tier live in production today; bridge and silo are a committed escalation path for a customer that needs stronger isolation, not a flag you flip per tenant yet.

Dev/insecure mode

A local development mode exists that parses a JWT without verifying its signature, but it still requires the tenant claim — local runs behave identically with respect to scoping. It must never run in a shared or production context; it trusts any unsigned token’s tenant claim.

See Security § Isolation model for the deeper security-review writeup (shared blast surface, mitigations, and what remains open).

Authentication

Every route under /api requires a bearer JWT, issued by the tenant’s Keycloak realm. Two public routes exist outside /api: GET /healthz and GET /readyz.

curl -sS "$RULEFLOW_API/api/whoami" -H "Authorization: Bearer $TOKEN"
{ "sub": "f2a1...", "email": "alice@acme.com", "tenant": "acme", "roles": ["business-analyst", "approver"] }

Token verification

The token is verified against the realm’s JWKS (issuer + /protocol/openid-connect/certs), algorithm RS256, expiry required, issuer matched case-insensitively. On success, the claims are attached to the request context for the rest of the handler chain.

Claims

FieldJSON keyNotes
SubjectsubKeycloak user id
Emailemailomitted if the user has none
Tenanttenantrequired on every tenant-scoped route
Rolesrolesrealm roles (realm_access.roles)

A request with no tenant claim (or an empty one) is rejected 403 on every tenant-scoped route — "no tenant in token". There is no default tenant. See Tenancy & isolation for how the claim is provisioned and why it can be trusted.

Roles

Roles gate specific actions, independent of tenancy:

RoleRequired for
approver (or admin)Deploying to / rolling back a protected environment (staging, prod, production)
approver (or admin)Completing an assigned human task on someone else’s behalf
adminEvery route under /admin/* (/admin/usage, /admin/tenants*) — deliberately cross-tenant surfaces

A missing role returns 403 with a message naming the required role, e.g. "admin/tenants requires the 'admin' role".

Errors

Every error response has the same shape:

{ "error": "human-readable message" }

Common status codes across the API: 400 invalid body/params, 403 no tenant claim or missing role, 404 not found (including cross-tenant lookups — see Tenancy), 409 idempotency-key conflict, 429 quota exceeded, 503 a backing service (engine, runtime, search) is not configured in this deployment.

Projects & artifacts

Decisions and workflows are both artifacts — same storage shape, same CRUD surface, distinguished by kind. Every route below is tenant-scoped (403 with no tenant claim) and grouped under a project path segment, which is just a label within the tenant (see Tenancy).

Artifact shape

{
  "id": "art_9f2c...",
  "tenant": "acme",
  "project": "lending",
  "kind": "decision",
  "name": "loan_risk",
  "model": { "...": "the decision or workflow JSON" }
}

Decisions

Method & pathDescription
POST /projects/{project}/decisionsCreate. Body: { "name": "...", "model": { ... } }
GET /projects/{project}/decisionsList all decisions in the project
GET /projects/{project}/decisions/{artifactId}Get one
PUT /projects/{project}/decisions/{artifactId}Replace name + model in place
DELETE /projects/{project}/decisions/{artifactId}Delete; audited as decision.deleted
POST /projects/{project}/decisions/{artifactId}/simulateRun the saved decision against a body of raw inputs
curl -sS -X POST "$RULEFLOW_API/api/projects/lending/decisions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "name": "loan_risk", "model": { "id": "loan_risk", "inputs": [...], "outputs": [...], "nodes": [...] } }'
{ "id": "art_9f2c...", "tenant": "acme", "project": "lending", "kind": "decision", "name": "loan_risk", "model": { "...": "..." } }

Simulate takes the raw input object as the body (not wrapped):

curl -sS -X POST "$RULEFLOW_API/api/projects/lending/decisions/art_9f2c.../simulate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "credit_score": 780, "debt_to_income": 0.2 }'

A successful simulate (2xx from the engine) records one decision_simulation unit of metered usage — see Usage & quotas.

Workflows

Method & pathDescription
POST /projects/{project}/workflowsCreate. Body: { "name": "...", "model": { ... } }
GET /projects/{project}/workflowsList
PUT /projects/{project}/workflows/{artifactId}Replace in place
DELETE /projects/{project}/workflows/{artifactId}Delete; audited as workflow.deleted
POST /projects/{project}/workflows/{artifactId}/compileCompile the saved workflow to ASL (read-only, not persisted)

There is no GET .../workflows/{artifactId} single-item route — list and filter client-side, or track ids from create/list responses.

Ad-hoc engine operations

Not persisted; useful while authoring before you save anything:

Method & pathForwards to
POST /engine/validate/v1/validate
POST /engine/simulate/v1/simulate
POST /engine/compile-workflow/v1/compile-workflow
POST /engine/diff/v1/diff

These relay the request body verbatim to the engine service and return its response verbatim (status code included) — a 503 means no engine is configured for this deployment.

Storage quota

If the deployment enforces MaxArtifactBytesPerTenant, POST (create only — not update) checks the tenant’s total stored artifact bytes before writing and returns 429 if the new artifact would exceed it:

{ "error": "artifact storage quota exceeded (max 5000000 bytes per tenant)" }

Audit

Audit entries are recorded on writes (deletes explicitly; create/update audit entries are recorded by the release/deploy flows, not per-artifact edit). Read them tenant- or project-scoped:

Method & pathDescription
GET /projects/{project}/auditThis project’s audit entries
GET /audit?project=&action=&actor=&from=&to=&limit=Tenant-wide, filterable (project omitted spans every project)
GET /audit/summaryAggregate counts (by action, actor, day) over the same filters
{ "id": "aud_1a2b...", "tenant": "acme", "project": "lending", "action": "release.created", "actor": "alice@acme.com", "detail": "v1 (3 item(s))" }

Releases & deployments

Releases

Method & pathDescription
POST /projects/{project}/releasesSnapshot every decision + workflow artifact in the project into a new immutable release
GET /projects/{project}/releasesList releases, newest first
GET /projects/{project}/releases/{releaseId}Get one release with its full item snapshot
GET /projects/{project}/releases/{releaseId}/diff/{otherId}Semantic diff between two releases
curl -sS -X POST "$RULEFLOW_API/api/projects/lending/releases" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "notes": "initial loan-approval flow" }'
{
  "id": "rel_4a1c...", "tenant": "acme", "project": "lending", "version": 1,
  "notes": "initial loan-approval flow", "createdBy": "alice@acme.com",
  "createdAt": "2026-07-21T18:00:00Z",
  "items": [
    { "kind": "decision", "name": "loan_risk", "model": { "...": "..." } },
    { "kind": "workflow", "name": "loan_application", "model": { "...": "..." } }
  ]
}

notes is optional; an empty request body is valid. Creating a release with nothing to snapshot (no decisions or workflows exist yet in the project) returns 400. If the deployment enforces MaxReleasesPerProject, exceeding it on create returns 429.

Deploy & rollback

Method & pathDescription
POST /projects/{project}/environments/{env}/deployPromote a release to an environment. Body: { "releaseId": "..." }
POST /projects/{project}/environments/{env}/rollbackRevert to the previously deployed release
GET /projects/{project}/environmentsList this project’s environments and their currently deployed release
curl -sS -X POST "$RULEFLOW_API/api/projects/lending/environments/staging/deploy" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "releaseId": "rel_4a1c..." }'
{ "environment": "staging", "releaseId": "rel_4a1c...", "version": 1, "updatedBy": "alice@acme.com" }

staging, prod and production are protected environments: deploying or rolling back requires the approver or admin realm role, or the request is rejected 403 with "deploying to staging requires the 'approver' role". Any other environment name deploys without an elevated role. Deploying a release that doesn’t exist returns 404; a workflow substitution/state-machine-creation failure returns 502 and never advances the environment’s active release.

Audit

See Projects & artifacts § Audit — the same tenant-wide/project-scoped audit routes cover release and deploy events (release.created, env.deployed, and their rollback equivalents).

Executions

Executions run a deployed workflow. Every route below is 503 if no workflow runtime is configured for this deployment.

Start an execution

POST /projects/{project}/executions
Body: { "workflow": "<name>", "env": "<env>", "input": { ... } }
curl -sS -X POST "$RULEFLOW_API/api/projects/lending/executions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: loan-app-2026-07-21-0001" \
  -d '{ "workflow": "loan_application", "env": "dev", "input": { "credit_score": 780 } }'
{ "id": "exec_88df...", "workflow": "loan_application", "env": "dev", "status": "RUNNING", "startedAt": "2026-07-21T18:05:00Z" }

workflow and env are required; a workflow not deployed to that environment returns 404. Starting an execution reserves a monthly execution slot against the tenant’s quota (429 if exhausted — see Usage & quotas) and records one workflow_execution unit of metered usage, best-effort, before calling into the runtime.

Idempotency

Pass an Idempotency-Key header. A retry with the same key and the same request re-attaches to the original execution instead of starting a second one. Reusing a key with a different request body returns 409:

{ "error": "idempotency key reused with a different request" }

Omitting the header starts a fresh execution every call — there is no implicit dedup on (workflow, env, input).

Observe

Method & pathDescription
GET /projects/{project}/executionsList this project’s executions, newest first
GET /projects/{project}/executions/{executionId}Get one execution merged with live status + history
GET /executions/search?q=&status=&workflow=&env=&from=&to=Tenant-wide search across projects
POST /projects/{project}/executions/reindexBackfill the search index from stored executions
curl -sS "$RULEFLOW_API/api/projects/lending/executions/exec_88df..." \
  -H "Authorization: Bearer $TOKEN"
{
  "execution": { "id": "exec_88df...", "status": "RUNNING", "startedAt": "2026-07-21T18:05:00Z" },
  "history": [ { "...": "per-state Step Functions history events" } ]
}

GetExecution always re-pulls live status from the runtime (poll-based, not push) — the stored record and the live description are merged on every read.

Human tasks

Method & pathDescription
GET /projects/{project}/executions/{executionId}/tasksList pending human tasks for this execution
POST /projects/{project}/executions/{executionId}/tasks/{stepId}/completeResume a paused human task
curl -sS -X POST \
  "$RULEFLOW_API/api/projects/lending/executions/exec_88df.../tasks/review/complete" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "output": { "signedOffBy": "underwriter-42" } }'
{ "status": "ok" }

Body: { "output": { ... }, "fail": false, "cause": "" } — all optional; an empty body is valid. Set "fail": true (with an optional "cause") to fail the task instead of completing it, which routes to the step’s catch branch if the author declared one. Completing a step with no pending task for that id returns 404.

Authorization: if the task has an assignee set, only that assignee (by token subject) or a caller holding approver/admin may complete it — otherwise 403. Unassigned tasks can be completed by any authenticated member of the tenant.

Connectors

Connector bindings map a workflow’s service_task logical action name to a connector type + config, per tenant. See Connectors § Overview for how a binding is used at execution time, and HTTP connector for the http type’s full config schema and error contract.

Method & pathDescription
GET /connectorsList the tenant’s connector bindings
PUT /connectors/{action}Create or replace a binding. Body: { "type": "echo|noop|http", "config": { ... } }
DELETE /connectors/{action}Remove a binding
curl -sS -X PUT "$RULEFLOW_API/api/connectors/disburse-funds" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "type": "http", "config": { "url": "https://payouts.acme.internal/disburse", "method": "POST", "auth": { "kind": "bearer", "token": "..." } } }'
{ "action": "disburse-funds", "type": "http" }
curl -sS "$RULEFLOW_API/api/connectors" -H "Authorization: Bearer $TOKEN"
[
  { "action": "disburse-funds", "type": "http", "config": { "...": "..." } },
  { "action": "notify", "type": "echo", "config": {} }
]

Built-in types

TypeBehavior
echoReturns the input verbatim as the task result — for wiring/testing a workflow’s service-task shape before a real integration exists
noopSucceeds immediately with an empty result
httpCalls an external HTTP endpoint — see HTTP connector

PUT with an unknown type returns 400. For type: "http", the config is validated against the locked schema (a required url, an optional method/auth.kind/timeoutMs/retry) before the binding is saved — see the next page for the full contract.

Config at rest

Connector configs may carry secrets (API keys, bearer tokens). They are encrypted at rest under a per-tenant envelope before being written to storage — see Encryption at rest.

Usage & quotas

RuleFlow meters two kinds of billable activity per tenant, per calendar month (period, "YYYY-MM"): workflow_execution (one unit per StartExecution call) and decision_simulation (one unit per successful stored-decision simulate call). Metering is best-effort — a metering write failure never blocks or fails the request it’s recording, the same posture the audit log takes.

Your tenant’s usage

GET /usage?from=2026-01&to=2026-08

from/to are both optional "YYYY-MM" bounds; omit both for the full history.

curl -sS "$RULEFLOW_API/api/usage?from=2026-06&to=2026-07" \
  -H "Authorization: Bearer $TOKEN"
{
  "periods": [
    { "period": "2026-06", "kind": "workflow_execution", "count": 412 },
    { "period": "2026-06", "kind": "decision_simulation", "count": 96 },
    { "period": "2026-07", "kind": "workflow_execution", "count": 208 }
  ]
}

Admin: every tenant’s usage

GET /admin/usage?period=2026-07

Requires the admin role — this is a deliberate, documented exception to “every operation is tenant-scoped,” reserved for cross-tenant reporting. Every route under /admin/* inherits this same gate. period is required; omitting it returns 400.

curl -sS "$RULEFLOW_API/api/admin/usage?period=2026-07" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
{
  "tenants": [
    { "tenant": "acme", "kinds": [
      { "period": "2026-07", "kind": "workflow_execution", "count": 208 },
      { "period": "2026-07", "kind": "decision_simulation", "count": 40 }
    ] },
    { "tenant": "globex", "kinds": [
      { "period": "2026-07", "kind": "workflow_execution", "count": 15 }
    ] }
  ]
}

A non-admin caller gets 403.

Quota behavior — 429

Starting an execution first reserves a slot against the tenant’s monthly execution limit. The limit is either a per-tenant override (tenants.max_executions_per_month, when set to a positive value on the tenant’s registry record) or the deployment’s process-wide default. Once the limit is reached for the current period, further StartExecution calls return:

HTTP 429
{ "error": "monthly execution quota exceeded (max 2)" }

The reservation happens before the runtime call, so a started execution always consumes a slot even if the downstream call subsequently fails — the slot is not refunded. A suspended tenant (status: "suspended" on its registry record) is rejected with 403 before a slot is ever reserved.

A separate quota guards artifact storage: creating a decision/workflow artifact that would push the tenant’s total stored bytes over MaxArtifactBytesPerTenant returns 429 (enforced on create only). A release-count quota (MaxReleasesPerProject) works the same way on release creation.

Admin: tenant registry

The tenant registry backs the per-tenant execution limit above and carries billing terms (money fields are always integer cents, never floats). Also admin-only.

Method & pathDescription
PUT /admin/tenants/{tenant}Provision or update a tenant’s plan/status/limits
GET /admin/tenantsList every tenant registry record
GET /admin/tenants/{tenant}Get one
curl -sS -X PUT "$RULEFLOW_API/api/admin/tenants/acme" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "display_name": "Acme Corp", "plan_code": "business", "status": "active",
    "base_cents": 250000, "included_executions": 5000, "overage_cents_per_1000": 8000,
    "currency": "BRL", "max_executions_per_month": 5000, "contact_email": "billing@acme.com"
  }'
{
  "tenant": "acme", "display_name": "Acme Corp", "plan_code": "business", "status": "active",
  "base_cents": 250000, "included_executions": 5000, "overage_cents_per_1000": 8000,
  "currency": "BRL", "max_executions_per_month": 5000, "contact_email": "billing@acme.com",
  "notes": "", "created_at": "2026-07-21T18:00:00Z"
}

plan_code must be one of trial, starter, business, ent-bridge, ent-silo; status must be active or suspended; all cents/count fields must be >= 0. The {tenant} path parameter always wins over any tenant field in the body. max_executions_per_month: 0 means “fall back to the deployment’s process-wide quota,” not “unlimited.” Provisioning is audited as tenant.provisioned on first write, tenant.updated on subsequent ones.

Connectors overview

A service_task step names a logical action (e.g. disburse-funds, notify) rather than a concrete integration. At the tenant level, that action is bound to a connector type and a config — the indirection lets the same workflow model call different real endpoints per tenant, or per environment, without touching the workflow itself. Bindings are managed via GET/PUT/DELETE /connectors.

How a service task executes

The compiled step resolves to a queued, task-token callback: the state machine sends a message (action, tenant, project, execution id, step id, task token, and the current workflow state as input) to a work queue and pauses. A connector worker consumes the message, resolves the tenant’s binding for that action, calls the matching handler, and reports the result back via a Step-Functions task-token success/failure call. On success the result lands at $.tasks.<stepId> in the workflow state, same as any other step result (see Workflows § reserved namespaces).

A failed message that a connector can’t complete dead-letters after a bounded number of receive attempts; the workflow’s own retry/catch on the step is what determines whether the step retries or an error branch is taken — the connector’s job is only to map the outcome (success, typed failure, or transport failure) into a SendTaskSuccess/SendTaskFailure call, never to reimplement workflow-level retry itself.

Built-in types

TypePurpose
echoReturns the input verbatim — for wiring and testing a workflow’s shape before a real integration exists
noopSucceeds immediately with an empty result — a deliberate no-op step
httpCalls an external HTTP endpoint with auth, request/response mapping, timeout and retry — see HTTP connector

Config secrecy

A connector config may carry a secret (an API key, a bearer token). Configs are encrypted at rest under a per-tenant envelope before being written — never plaintext — and only decrypted by the connector worker at call time. See Encryption at rest.

Adding a binding

curl -sS -X PUT "$RULEFLOW_API/api/connectors/notify" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "type": "echo", "config": {} }'

A workflow’s service_task with "action": "notify" then resolves to this binding at runtime, for this tenant, in every environment — bind a different config (or type) per tenant to point the same logical action at a different real endpoint.

HTTP connector

The http connector calls an external HTTP(S) endpoint from a service_task. This is the locked, as-built config contract — the same schema the control plane validates on PUT /connectors/{action} and the worker executes.

Config schema

{
  "url": "https://...",           // required; must start with http:// or https://
  "method": "POST",               // optional; GET|POST|PUT|PATCH|DELETE, default POST
  "headers": { "X-Foo": "bar" },  // optional, passed through verbatim

  "auth": {                       // optional; one of four kinds
    "kind": "none"
    // "kind": "bearer", "token": "..."
    // "kind": "apiKey", "header": "X-API-Key", "value": "..."   // header defaults to X-API-Key
    // "kind": "basic",  "username": "...", "password": "..."
    // "secretRef" is reserved for a future Secrets Manager integration —
    //   v1 does not resolve it. If present without the matching inline
    //   secret, the call fails closed with Http.ConfigError.
  },

  "bodyTemplate": "...",          // optional; empty = the input object verbatim.
                                   //   Tokens: {{input}}, {{input.a.b}}, {{$.a.b}}
                                   //   (a missing path resolves to null)
  "responseMap": { "field": "$.a.b" }, // optional; empty = { connector, status, body };
                                        //   otherwise an object with only the mapped fields

  "timeoutMs": 20000,             // optional, default 20000, per request
  "retry": { "max": 2, "backoffMs": 500 } // optional; retries only transport
                                           //   errors and 5xx — never 4xx
}

Secrets (auth.token, auth.value, auth.password) are stored inline in the config in v1 — never as plaintext at rest, though: the whole config is encrypted under the tenant’s envelope before being persisted (see Encryption at rest). A future secretRef pointing at a secrets manager is reserved but not implemented.

Auth kinds

kindFieldsBehavior
none (or omitted)no auth header added
bearertokenAuthorization: Bearer <token>
apiKeyheader (default X-API-Key), valuesets the named header to value
basicusername, passwordHTTP Basic auth header

Request construction

  • Body: bodyTemplate maps the input/state into the request body; an empty/absent template sends the input object verbatim. Available tokens: {{input}} (the whole input), {{input.a.b}} / {{$.a.b}} (a specific path — missing paths resolve to null, never an error).
  • Response mapping: responseMap picks specific fields out of the HTTP response via dot/JSONPath-style expressions into the task’s output object. With no responseMap, the task result is { connector, status, body } — the raw response wrapped with metadata.
  • Timeout & retry: timeoutMs (default 20000) bounds each individual request; retry.max/retry.backoffMs govern retries within this HTTP call only — transport errors and 5xx responses are retried up to max times with backoff; 4xx responses are never retried. This is independent of, and layered under, the workflow step’s own retry/catch.

Error contract

Every failure maps to a typed Step Functions error name, so a workflow’s retry/catch on the service_task can key off it precisely:

ConditionError nameRetries at the workflow level?
Missing url / invalid config JSON / malformed auth / secretRef with no matching secretHttp.ConfigErrorNo
Response 4xxHttp.ClientErrorNo
Response 5xxHttp.ServerErrorYes (up to retry.max)
Transport deadline / timeoutHttp.TimeoutYes
Other transport errorHttp.RequestErrorYes
Untyped error (other connector types, unknown type)ConnectorErrorWorker-dependent fallback

Config-shape errors (a missing url, a malformed auth block, an unknown auth.kind) are also rejected synchronously at bind time — PUT /connectors/{action} returns 400 before the binding is ever saved, so a broken config never reaches a running workflow in the first place. The table above covers failures that only surface at call time (a misconfigured but structurally valid secretRef, or the target endpoint itself failing).

Example

curl -sS -X PUT "$RULEFLOW_API/api/connectors/disburse-funds" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "type": "http",
    "config": {
      "url": "https://payouts.acme.internal/v1/disburse",
      "method": "POST",
      "auth": { "kind": "bearer", "token": "sk_live_..." },
      "bodyTemplate": "{ \"amount\": {{input.requested_amount}}, \"account\": {{input.account_id}} }",
      "responseMap": { "payoutId": "$.id", "status": "$.status" },
      "timeoutMs": 10000,
      "retry": { "max": 2, "backoffMs": 500 }
    }
  }'

A service_task with "action": "disburse-funds" and a catch on Http.ServerError/Http.Timeout will route to its error branch after exhausting retries; a Http.ClientError (e.g. a 400 from the payout service — a config problem) fails immediately without retrying, since retrying an unchanged bad request would never succeed.

Isolation model

RuleFlow runs pooled multi-tenant by default: shared services and a shared database, with every row/partition scoped by the caller’s verified tenant claim. The model, in one sentence: every operation acts only as the tenant in the verified token. Its strength depends on exactly two things — the token cannot lie about the tenant, and no code path can forget the filter — both pinned structurally, not by convention. A conceptual introduction is in Tenancy & isolation; this page goes one layer deeper, into where the pooled model’s shared blast radius is mitigated and where it is still open.

Root of trust for the tenant claim

  • Direct userstenant is a per-user attribute mapped into the access token by a default client scope present on every client (studio, control-plane, CLI). It is admin-provisioned: self-service registration is disabled, so no user can choose their own tenant.
  • Federated (SAML) users — the critical case. When a tenant’s SAML IdP brokers in, the tenant claim is set by a mapper hardcoded at onboarding time to the tenant that IdP was onboarded for. The SAML assertion’s own tenant attribute is ignored — an IdP cannot assert a different tenant. Brokering additionally requires signed assertions (blocking XML signature wrapping) and disables email-based trust (blocking account takeover via a spoofed email claim).
  • Service accounts — the control plane’s own machine client has no user and therefore no tenant attribute; its tokens carry no tenant claim at all. This is safe only because of reject-on-missing: a tenant-less token is 403 on every tenant-scoped route. The standing rule: nothing may ever treat a missing tenant as a wildcard.

Three enforcement layers

  1. Identity — the claim’s provenance above.
  2. Contract — every persistence method takes tenant as a required parameter; there is no method that reads across tenants. This is the load-bearing layer: because tenancy is a parameter of the storage interface itself, a handler cannot even express a cross-tenant query — it’s a compile-time impossibility, not a code-review hope.
  3. Infrastructure — tenant is the leading dimension of physical data layout: partition keys, index leading columns, and mandatory search filters all lead with tenant.

Cross-tenant access looks like “not found”

Fetching another tenant’s object by id returns the same typed not-found error whether the id doesn’t exist or belongs to someone else — a 404, never a 403 that would reveal the object exists. Listings for another tenant are simply empty.

project is a label, not a boundary

project is an unvalidated grouping label within a tenant. There is no isolation between two projects belonging to the same tenant — any member of a tenant can read any of that tenant’s projects. If you need per-project confinement (e.g. contractors restricted to one project), that is a new control on top of this model, not something the platform enforces today.

The shared blast surface, and what mitigates it

Pooled tenancy means several resources are genuinely shared. Here is the honest inventory:

Shared resourceRiskStatus
One API gatewayone tenant’s traffic degrades othersPer-tenant rate quota — live
One relational database + one NoSQL tablenoisy queries, hot partitionsRow/partition-scoped by tenant; no per-tenant DB resource quota
One decision-execution compute poola high-fanout tenant can starve others’ decision callsUnaddressed — no reserved/provisioned concurrency per tenant yet
One encryption keyshared request quotaPer-tenant context is bound into every envelope (see Encryption); key-level caching deferred
No resource quotas on stored executions/artifactsunbounded growthArtifact storage is quota-capped; execution/task retention is not yet bounded

Isolation tiers

Pooled is the only tier that runs in production today. Bridge (per-tenant schema/database, shared services) and silo (a fully dedicated stack per tenant) exist as deployment tooling for a customer that contractually needs stronger separation, but there is no app-level routing that switches a live tenant between tiers yet — moving a tenant is a data-placement operation the platform doesn’t automate today, not a flag you flip. Because authorization is always “act as the token’s tenant” regardless of tier, the API surface and authored rules are identical across all three — the tier only changes where the bytes physically live.

What this model does not (yet) cover

  • Per-tenant compute isolation (decision-execution concurrency) — open; a candidate mitigation is reserved/provisioned concurrency per environment, escalating to silo for a tenant that needs a dedicated compute envelope.
  • Data residency / region pinning — open.
  • Retention on execution/human-task data — no automatic expiry yet; accumulates until a retention policy is added.

These are documented gaps, not silent ones — see the platform’s design records for the sprint that will close each.

Encryption at rest

Sensitive payloads are sealed under a per-tenant envelope before being written to storage, rather than relying solely on a provider’s default storage-level encryption. The design goal: even with access to the raw table, one tenant’s plaintext should never be recoverable using another tenant’s key material or context.

Envelope model

  • A shared root key (customer-managed) wraps a per-write data key.
  • The tenant is bound into the envelope as encryption context/AAD: a ciphertext sealed for tenant A cryptographically cannot be opened using tenant B’s context, even under the same root key. This is a second, independent tenant boundary layered under the application-level checks described in Isolation model — a defense-in-depth property, not a replacement for them.
  • Ciphertext is framed with a version byte so the format itself is versioned and can evolve without breaking old records.

What is covered today

  • Connector configs (config_enc) — a connector binding’s config, which may carry an API key, bearer token or basic-auth password, is sealed before being persisted. Only config (plaintext) exists for bindings created before encryption was enabled; readers accept both (config_enc preferred, config as a compatibility fallback) so older bindings keep working, and can be re-sealed on their next update.
  • Fail-closed behavior: if a stored value indicates it should be encrypted but the decrypting process has no key access configured, the read fails rather than silently returning ciphertext or skipping decryption.

What is not yet covered

Execution and human-task payloads (input/output/form data flowing through workflow runs) currently rely on the storage provider’s own default encryption rather than the per-tenant envelope above. Bringing these under the same tenant-bound envelope — either at the storage layer or per-attribute before write — is tracked as a follow-up; until then, treat workflow input/output as not cryptographically tenant-isolated beyond the application-level checks in Isolation model.

Practical implication for connector configs

Because a connector config is encrypted at rest, GET /connectors returns the config as the worker will use it (decrypted, for the caller’s own tenant) — the ciphertext form is never exposed over the API. A secret placed in a connector config is protected from anyone with read access to the raw database, but is still visible to any authenticated member of the tenant via the API — connector config secrets are a tenant-shared credential, not a per-user one.

SSO / SAML

RuleFlow’s identity plane is Keycloak: OIDC/OAuth2 for direct users, and SAML 2.0 for enterprise single sign-on. Every RuleFlow API token — however the user authenticated — carries the same claim shape (see Authentication), so SSO changes how a user proves who they are, not what the rest of the platform sees.

How a SAML tenant is onboarded

A tenant’s SAML identity provider is brokered into the realm as a dedicated identity-provider configuration, onboarded per tenant. Two properties are enforced at onboarding, not left to the IdP’s default posture:

  • The tenant claim is pinned, not imported. A hardcoded-attribute mapper sets tenant to the value the IdP was onboarded for; the SAML assertion’s own tenant attribute (if it sends one) is ignored. This closes the class of bug where a federated IdP could assert membership in a tenant other than the one it was actually onboarded for — the tenant boundary depends on this being true (see Isolation model).
  • Signed assertions are required. Onboarding an IdP with no signing certificate is refused; assertion signature validation is on, which blocks XML Signature Wrapping attacks against the SAML response.

Email-based account linking/trust is disabled for brokered identities, which closes an account-takeover path where an attacker-controlled IdP could assert an existing user’s email to hijack their account.

What SSO does and does not change

  • Does change: login flow, session issuance, and where credentials live (the enterprise’s own IdP, not Keycloak-managed passwords).
  • Does not change: role assignment mechanics, tenant scoping, or API behavior. A federated user’s token is verified the same way, carries the same claim shape, and is subject to the same 403-on-missing-tenant rule as a directly-provisioned user.

Provisioning

Users — direct or federated — are provisioned on the identity side, not via self-service signup: the realm disables open registration, so there is no flow by which a user picks their own tenant. Onboarding a new tenant (including its SAML brokering, if applicable) is an administrative, identity-side operation that happens before that tenant’s users can obtain a token — the control plane trusts the claim once it arrives, but does not issue it.