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 anapproverrole. - 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 type | Executes as |
|---|---|
decision_task | in-process call into the Rust decision engine (Lambda) |
service_task | a connector call via a queued, task-token callback |
human_task | a paused execution waiting on a task token, resumed by an API call |
gateway | a branch on prior decision/task output |
wait / end | timer / 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.45is a comparator;-is a wildcard that always matches. See Expressions § decision-table cells for the full predicate syntax.- The
outputnode 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 Artifact — id 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.gatereads$.decisions.price.approved(theloan_pricingdecision’sapprovedoutput) via thevariablefield.human_task— pauses the execution until a person completes it via the API (see Executions);assigneeis stored but authorization also accepts anapprover/adminrole.service_task— calls out to an external system through a connector;retry/catchfollow Step Functions’Retry/Catchshape.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>.
decisionsandtasksare reserved top-level keys in workflow/decision state. Don’t name an input schema fielddecisionsortasks— 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_policycontrols 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/thenpairs) with adefaultfallback; 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}/simulatewith the input object as the body. - Simulate ad hoc:
POST /engine/simulatewith{ 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.0istrue. (There is no==—=alone is equality.) - Ordering
< <= > >=compares numbers, dates and strings (lexicographically); comparing incomparable types is an error, not a silentfalse. - Membership
x in [a, b, c]istruewhenxequals any element. - Logical
and/orshort-circuit;notnegates a boolean. A non-boolean operand to a logical operator is an error.
Functions
| Function | Arity | Notes |
|---|---|---|
if(cond, a, b) | 3 | lazy — only the taken branch is evaluated |
min(…), max(…) | ≥1 numeric | variadic |
abs(n) | 1 numeric | |
round(n), floor(n), ceil(n) | 1 numeric | |
len(x) | 1 | string (chars), array or object length |
contains(hay, needle) | 2 | substring (string) or element membership (array) |
startsWith(s, prefix) | 2 | strings |
lower(s), upper(s) | 1 | strings |
coalesce(…) | ≥0 | first non-null argument, else null |
dateDiffDays(a, b) | 2 dates | a - 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:
| Form | Meaning |
|---|---|
- (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/simulateuses, 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 logicalactionname 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/valueagainst a$.decisions.…/$.tasks.…path), with adefaultbranch.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 eachdecision_task.$.tasks.<stepId>— the result of eachservice_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:
- 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-declaredcatchon the step is the way to handle them. - Transient AWS faults (throttling, a cold-start hiccup) are not
reproducible and should be retried with backoff — declare a
retryon the step for this class. - 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 whenprojectis omitted. Pairs withGET /audit/summaryfor 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) requiresapproveroradmin. - Completing a human task requires being the task’s
assignee, or holdingapprover/admin, when the task has an assignee set. - Admin-only, cross-tenant reporting routes (
/admin/usage,/admin/tenants) requireadmin.
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
- Identity (Keycloak). The
tenantclaim’s provenance: admin-provisioned for direct users, hardcoded per-IdP at SAML brokering onboarding time for federated users (the SAML assertion’s owntenantattribute 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. - Contract (the
Store). Every store method takestenantas 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. - 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 withtenant.
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
| Tier | What is shared | Status |
|---|---|---|
| Pooled | Shared services + database; row/partition-level scoping by tenant claim | The only tier that runs today; default for all tenants |
| Bridge | Shared services; per-tenant schema/database | Tooling exists; no app-level routing yet |
| Silo | Dedicated stack per tenant | Deployment 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
| Field | JSON key | Notes |
|---|---|---|
| Subject | sub | Keycloak user id |
email | omitted if the user has none | |
| Tenant | tenant | required on every tenant-scoped route |
| Roles | roles | realm 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:
| Role | Required 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 |
admin | Every 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 & path | Description |
|---|---|
POST /projects/{project}/decisions | Create. Body: { "name": "...", "model": { ... } } |
GET /projects/{project}/decisions | List 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}/simulate | Run 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 & path | Description |
|---|---|
POST /projects/{project}/workflows | Create. Body: { "name": "...", "model": { ... } } |
GET /projects/{project}/workflows | List |
PUT /projects/{project}/workflows/{artifactId} | Replace in place |
DELETE /projects/{project}/workflows/{artifactId} | Delete; audited as workflow.deleted |
POST /projects/{project}/workflows/{artifactId}/compile | Compile 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 & path | Forwards 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 & path | Description |
|---|---|
GET /projects/{project}/audit | This project’s audit entries |
GET /audit?project=&action=&actor=&from=&to=&limit= | Tenant-wide, filterable (project omitted spans every project) |
GET /audit/summary | Aggregate 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 & path | Description |
|---|---|
POST /projects/{project}/releases | Snapshot every decision + workflow artifact in the project into a new immutable release |
GET /projects/{project}/releases | List 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 & path | Description |
|---|---|
POST /projects/{project}/environments/{env}/deploy | Promote a release to an environment. Body: { "releaseId": "..." } |
POST /projects/{project}/environments/{env}/rollback | Revert to the previously deployed release |
GET /projects/{project}/environments | List 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 & path | Description |
|---|---|
GET /projects/{project}/executions | List 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/reindex | Backfill 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 & path | Description |
|---|---|
GET /projects/{project}/executions/{executionId}/tasks | List pending human tasks for this execution |
POST /projects/{project}/executions/{executionId}/tasks/{stepId}/complete | Resume 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 & path | Description |
|---|---|
GET /connectors | List 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
| Type | Behavior |
|---|---|
echo | Returns the input verbatim as the task result — for wiring/testing a workflow’s service-task shape before a real integration exists |
noop | Succeeds immediately with an empty result |
http | Calls 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 & path | Description |
|---|---|
PUT /admin/tenants/{tenant} | Provision or update a tenant’s plan/status/limits |
GET /admin/tenants | List 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
| Type | Purpose |
|---|---|
echo | Returns the input verbatim — for wiring and testing a workflow’s shape before a real integration exists |
noop | Succeeds immediately with an empty result — a deliberate no-op step |
http | Calls 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
kind | Fields | Behavior |
|---|---|---|
none (or omitted) | — | no auth header added |
bearer | token | Authorization: Bearer <token> |
apiKey | header (default X-API-Key), value | sets the named header to value |
basic | username, password | HTTP Basic auth header |
Request construction
- Body:
bodyTemplatemaps 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 tonull, never an error). - Response mapping:
responseMappicks specific fields out of the HTTP response via dot/JSONPath-style expressions into the task’s output object. With noresponseMap, 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.backoffMsgovern retries within this HTTP call only — transport errors and 5xx responses are retried up tomaxtimes with backoff; 4xx responses are never retried. This is independent of, and layered under, the workflow step’s ownretry/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:
| Condition | Error name | Retries at the workflow level? |
|---|---|---|
Missing url / invalid config JSON / malformed auth / secretRef with no matching secret | Http.ConfigError | No |
Response 4xx | Http.ClientError | No |
Response 5xx | Http.ServerError | Yes (up to retry.max) |
| Transport deadline / timeout | Http.Timeout | Yes |
| Other transport error | Http.RequestError | Yes |
| Untyped error (other connector types, unknown type) | ConnectorError | Worker-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 users —
tenantis 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
tenantclaim is set by a mapper hardcoded at onboarding time to the tenant that IdP was onboarded for. The SAML assertion’s owntenantattribute 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
tenantclaim at all. This is safe only because of reject-on-missing: a tenant-less token is403on every tenant-scoped route. The standing rule: nothing may ever treat a missing tenant as a wildcard.
Three enforcement layers
- Identity — the claim’s provenance above.
- Contract — every persistence method takes
tenantas 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. - 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 resource | Risk | Status |
|---|---|---|
| One API gateway | one tenant’s traffic degrades others | Per-tenant rate quota — live |
| One relational database + one NoSQL table | noisy queries, hot partitions | Row/partition-scoped by tenant; no per-tenant DB resource quota |
| One decision-execution compute pool | a high-fanout tenant can starve others’ decision calls | Unaddressed — no reserved/provisioned concurrency per tenant yet |
| One encryption key | shared request quota | Per-tenant context is bound into every envelope (see Encryption); key-level caching deferred |
| No resource quotas on stored executions/artifacts | unbounded growth | Artifact 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. Onlyconfig(plaintext) exists for bindings created before encryption was enabled; readers accept both (config_encpreferred,configas 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
tenantclaim is pinned, not imported. A hardcoded-attribute mapper setstenantto the value the IdP was onboarded for; the SAML assertion’s owntenantattribute (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.