Concepts
Workflow, stage, step, field
Workflow what runs <Workflow id="loan_v1">
└ Stage who does it <Stage name="apply" actor="role:applicant">
└ Step one page of it <Step name="income">
└ Field one answer <Field name="salary" type="currency" required />- A stage belongs to one actor — a role like
role:applicantorrole:underwriter. Only that role may answer its fields or submit it. Stages are the security boundary. - A step is pagination inside a stage. It has no security meaning;
showIfhides a step. - A field is one answer, validated on the client for feedback and on the server for real.
<FieldGroup>is a repeatable group (dependents, line items).
The JSX is the source of truth, but the engine never runs your code. clipless deploy reads the workflow out of your file — stages, roles, fields, rules — and the engine enforces that.
Sessions and actors
A session is one run of a workflow: one claim, one application. Your backend starts it with an API key (POST /workflows/:id/sessions) and gets a token for an actor. A token is scoped to one session and one role; your frontend passes it to <Workflow runtimeToken>.
The engine takes the actor from the token, never from the request. It refuses:
- events from a role that does not own the active stage,
- fields that are not in that stage,
- anything once the stage is submitted and locked,
- answers to a
mode="readonly"stage.
Routing
When a stage is submitted, the engine validates it, runs its actions, locks it, and decides what comes next:
- The first of the stage's
transitionswhose condition holds — which may point back to an earlier stage (Loop-backs). - Otherwise the next stage in order whose
renderIfholds. A stage whoserenderIffails is skipped. - No stage left: the session is complete.
Conditions are plain objects, evaluated against the answers and action results:
ts
{ decision: 'Approve' } // equals
{ decision: ['Approve', 'Escalate'] } // one of
{ amount: { gte: 1000 } } // eq ne gt gte lt lte in nin
{ country: 'US', amount: { gt: 0 } } // all of
{ $or: [{ vip: true }, { amount: { lt: 50 } }] }
{ $not: { country: 'US' } }
{ 'risk.level': 'high' } // an action's result, by action idThe same conditions drive renderIf (stage), showIf (step, field), requiredIf and disabledIf (field).
The ledger
Nothing is updated in place. Every change is an event appended to the session's ledger, with a sequence number and a SHA-256 hash of the one before it, so tampering shows.
| From the client | From the engine |
|---|---|
field — an answer was set | stage_enter / stage_complete / stage_reopen |
step_enter — moved to a step | stage_transition — routed to a stage |
repeat_add / repeat_remove / repeat_undo | action_dispatch / action_complete / action_failed |
remark — a comment on a field |
Typing is not saved letter by letter. The screen updates on every keystroke; the ledger gets onefield event per field when typing pauses (half a second), or at once when the field loses focus, the stage is submitted, the step changes, a field action needs the value, or the page is closed.
The session's state — current stage, answers, action results, what is locked — is the ledger replayed. So a draft is just a ledger that stops, history is the ledger read back, and "what did the applicant enter before the reviewer asked for changes" is answered without anyone designing for it.
Answers the engine writes itself — prefill given at the start, answers carried from last year — are field events from system, marked with where they came from.
Rounds
Each entry into a stage is a round. Usually every stage has one. When a workflow loops back — a manager sends a claim back to the employee — the claim gets a round 2 and the review a round 2 after it, and each earlier round keeps the answers it left. useRounds() lists them. See Loop-backs and rounds.
Visibility
By default everyone in a session can read every answer. visibleTo narrows it:
tsx
<Stage name="underwriting" actor="role:underwriter" visibleTo={['role:loan_officer']}>
<Field name="internal_notes" type="textarea" /> {/* underwriter + officer */}
<Field name="apr" type="number" visibleTo="everyone" /> {/* re-opened to all */}
</Stage>A stage's owner always sees its own answers. Fields inherit the stage's visibleTo; <Action visibleTo> hides an action's result. The engine redacts every response per role — state, ledger, history — so a hidden answer never reaches that role's browser. Deploy refuses a workflow in which a role's own conditions read something that role cannot see.
Sensitive data
vault— encrypted at rest, with a key derived per session. Erasing one session's key (crypto-shredding) makes its vault answers, and its generated documents, unreadable without touching the ledger's hash chain or any other session.pii— personal data that is not encrypted: kept out of the plaintext cache on the session row and masked inclipless sessionunless--reveal.
Revisions
Every deploy of a changed workflow is kept as a revision. A session stays on the revision it started on: deploying a new required field does not break the sessions already running, and a finished session is always read against the form it was filled in with.
Environments
An API key belongs to one environment — say test or production — and sees only that environment's sessions. Secrets (clipless secrets) and the webhook signing secret are per environment too.