Loop-backs and rounds
Real reviews send things back: "the receipt is wrong, fix it and resubmit". A stage's transitions can point back to an earlier stage.
Try it
An expense claim that the manager sends back for changes in the playground runs in your browser. Play every role from the toolbar.
tsx
<Stage name="claim" actor="role:employee">
<SentBack />
<Field name="amount" type="number" required />
<Field name="receipt" type="file" required />
</Stage>
<Stage
name="review"
actor="role:manager"
fresh
transitions={[{ targetStageId: 'claim', condition: { decision: { eq: 'Request changes' } } }]}
>
<Field
name="decision"
type="select"
required
options={[{ value: 'Approve' }, { value: 'Request changes' }, { value: 'Reject' }]}
/>
<Field name="review_note" type="textarea" requiredIf={{ decision: { eq: 'Request changes' } }} />
</Stage>When the manager picks Request changes, the engine routes back to claim and reopens it for the employee. They fix it and submit; the review comes round again.
transitions are tried in order when the stage completes; the first whose condition holds wins, and an edge without a condition always matches. If none match, the next stage in order follows. An edge to a stage that does not exist fails at deploy.
Rounds
Each entry into a stage is a round. After one send-back the session has:
| Round | Answers it left |
|---|---|
| claim #1 | amount 120, receipt …/old |
| review #1 | decision Request changes, note "wrong receipt" |
| claim #2 | amount 120, receipt …/new |
| review #2 | (current) |
- The session's answers are always the latest value of each field.
- Each round keeps what it left, so the history is there without you storing it: it is the ledger, read back.
- An employee re-entering
claimstarts from their previous answers — they are there to fix. - A
freshstage starts each round empty instead: the manager decides again rather than inheriting last round's Request changes. The earlier round keeps its answers.
useRounds() lists the rounds (stage, round number, status, the answers the viewer may read) — enough to draw a timeline. clipless session prints them for any session that went round more than once.
Showing why it came back
The review's answers are in the session, so the employee's stage can show them:
tsx
function SentBack() {
const { answers } = useWorkflowState()
if (answers.decision !== 'Request changes') return null
return <Alert>Sent back by your manager: {String(answers.review_note)}</Alert>
}(If the manager's stage were visibleTo only managers, the employee would not see the note — the engine redacts it. Re-open a field with visibleTo="everyone".)
Actions on every round
An on="enter" action runs on every entry, so it is the place for "your claim came back" or "a claim is waiting for your review":
tsx
<Stage name="review" actor="role:manager" fresh transitions={…}>
<Action id="notify_manager" type="email" on="enter" to="{{manager_email}}" subject="Claim to review" />
…
</Stage>