current_state cursor; every step appends to approval_transitions
as a replay log; advancement is driven by approval:advance,
approval:resume, and approval:timeout-sweep jobs on the existing
outbox. Restart-safety, retries, and deadline scheduling are inherited
from core/jobs — no second scheduler.
1. Concepts
Four tables (seecore/db/migrations/0016_workflow.sql (renamed by 0022)) are all you
need to reason about a run.
approval_defs— one row per slug per version.spec_jsonholds the normalized JSON definition,active=1selects the live version. Registering a new version bumps the prior one to inactive in the same tx, so exactly one is current per slug.approval_runs— one row per instance.stateisrunning | done | failed | cancelled;current_stateis the cursor intospec_json.states;deadline_atis the epoch the sweeper compares against.approval_transitions— append-only audit log.triggeris one ofsystem,timeout,approve,reject,cancel, or a custom event.actorisuser:N,token:N, orsystem.approval_tasks— human-in-the-loop rows spawned byapprove-kind states.statusisopen | claimed | resolved | expired.
Event="" (typically after
spawning a task). The sweeper fires trigger="timeout" when
deadline_at <= now. The runner special-cases kind: "end" — it’s
the only terminal marker; other kinds keep advancing as long as their
handler emits an event mapped in state.On.
2. Spec shape
The JSON body an admin POSTs. Source of truth iscore/approvals/spec.go.
Spec.Validate() runs pre-persist and enforces:
startmust exist instates.- State keys match
^[a-zA-Z][a-zA-Z0-9_]{0,63}$. - Assignees match
user:<positive-int>orrole:<slug>. Nothing else reaches SQL. kind: "approve"requires a non-emptyassigneeand at least onechoice; every choice must have a mapping inon.kind: "end"must not declare anyontransitions.- Every
on[event]target must be a state that exists. timeout_secmust be non-negative.
Validate, Register cross-checks every state’s kind
against the handler registry and refuses unknown kinds with
ErrUnknownHandler (HTTP code unknown_handler).
Built-in state kinds
Three ship incore/approvals/handler.go:
system— passthrough. Empty trigger emitssuccess; otherwise echoes the trigger. Useful as a routing hub.approve— spawns aapproval_tasksrow on entry and parks. On resume the trigger (the choice string, or"timeout") is emitted as the event. Taskdeadline_atinheritsstate.timeout_sec.end— terminal. The runner finalizes the run tostate='done'and expires open tasks in the same tx.
Engine.RegisterHandler — see §6.
3. HTTP surface
Six endpoints incore/api/approvals.go, mounted under
/api/approvals/*.
{slug} is constrained to ^[a-z][a-z0-9_\-]{0,63}$ — greppable and
URL-safe.
Error codes worth handling:
4. Worked example — 3-step invoice approval
Assumes suchi is running onhttp://localhost:8000 and $TOKEN holds
an admin API token minted via POST /api/login.
Step 1: Register the spec
201:
Step 2: Start a run against doc 17
201:
approval:advance job fires
immediately. The approve handler spawns a approval_tasks row
assigned to user:5 and parks the run.
Step 3: Inspect the run
{run, transitions: [], tasks: [<task 101 open on user:5>]}.
No transitions yet — the first row is written when the state
transitions out, not on entry.
Step 4: Manager approves
user:5 (or an admin) resolves task 101:
204. The engine enqueues approval:advance with
trigger="approve", maps it via on to finance_review, and spawns
the next task on user:9.
Step 5: Finance approves
GET /api/approvals/runs/42 now shows Status: "done",
CurrentState: "approved", and the transitions log:
The
trigger on human steps is the choice string itself, not a
literal "approve". cancel transitions carry
actor=user:<admin>.
5. Timeouts and the sweeper
state.timeout_sec sets approval_runs.deadline_at on entry. Every
30 seconds (see SweepInterval in core/approvals/sweeper.go) the
approval:timeout-sweep job scans:
approval:advance with
trigger="timeout" and nulls deadline_at in the same tx so the row
can’t be swept twice. The state’s handler then emits "timeout" as
the event; if on.timeout isn’t mapped, the advance job errors and
the run stalls (the outbox retries with backoff and eventually
marks the job dead).
The sweep loop is self-scheduling: at boot,
Engine.EnsureSweepScheduled inserts a pending sweep row if none
exists (wired at distro/cmd/suchi/main.go). Each sweep run
re-enqueues the next one, guarded by a SELECT COUNT(*) ... WHERE kind='approval:timeout-sweep' AND state='pending' so a double-invoke
never backlogs the queue.
Task rows inherit their deadline_at from TaskSpec.DeadlineIn
(0 → falls back to state.timeout_sec). When a run times out,
the task flips to expired before the run transitions on.
6. Extending with a Handler
- Handlers MUST be retry-safe — the outbox delivers at-least-once.
- Register at boot, before
approvals.SetDefault(engine). Last-write-wins byKind(), so built-ins can be overridden. - Return
Event=""with noTaskto park indefinitely — the run waits for an externalResolve/Cancel/ timeout.
AssigneeResolver
The second seam. Default resolver acceptsuser:N and rejects
role:* with ErrRoleUnresolved. Enterprise RBAC swaps in via
engine.SetAssigneeResolver(r) before boot.
Resolve inside the task-creation tx: an error
aborts the write, the advance job retries, and the run stays
running so the operator can fix wiring and re-drive it.
7. Operations
- Log tag — every log line carries
component=workflow. Grep onworkflow.advance.transition,workflow.sweep.fired,workflow.resolve,workflow.advance.parkwhen a run stalls. - Outbox visibility — the three job kinds
(
approval:advance,approval:resume,approval:timeout-sweep) show up on/api/tasks/. Filter with?kind=workflow:for the subsystem’s queue depth. - Cancel semantics — writes a
trigger='cancel'transition withfrom == to == current_state,reasoninpayload_json, expires open tasks, finalizes tostate='cancelled'. Cancelling a terminal run returns409 terminal. - Idempotent advance —
Advanceon a non-runningrun logsworkflow.advance.skip_terminaland returns nil, so redelivered outbox jobs after finalization are safe.
Not in scope (deferred)
- Parallel / fan-out states. One
current_statecursor per run; AND-splits are v2. - YAML definition loader. Specs are JSON in
approval_defs.spec_json; a YAML compiler is additive and needs no migration. No watched-directory boot-time loader yet. role:Xresolution + fair dispatch.AssigneeResolveris reserved for enterprise; core ships onlyuser:N.Resolvereturns[]int64for eventual round-robin, but the runner doesn’t consume the list yet.- Merging
approval_tasksinto/api/tasks/. Today the outbox andapproval_tasksare surfaced separately; the mobile-compat single-poll story lands with Phase 4.