Skip to main content
Approvals are the human-in-the-loop counterpart to automations. Automations run “when X, do Y” against document metadata with no human involvement. Approvals move a document through an ordered chain of people — doc 17 needs the finance lead to click Approve before it lands in the vault. Approvals also sit next to agents. Agents are horizontal work injection — anything speaking HTTP claims a job. Approvals are ordered, resumable review chains: doc 17 needs manager sign-off, then finance sign-off, escalating on timeout. The design is a hybrid state-machine + job-graph. Each run keeps one 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 (see core/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_json holds the normalized JSON definition, active=1 selects 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. state is running | done | failed | cancelled; current_state is the cursor into spec_json.states; deadline_at is the epoch the sweeper compares against.
  • approval_transitions — append-only audit log. trigger is one of system, timeout, approve, reject, cancel, or a custom event. actor is user:N, token:N, or system.
  • approval_tasks — human-in-the-loop rows spawned by approve-kind states. status is open | claimed | resolved | expired.
A run parks whenever a handler returns 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 is core/approvals/spec.go.
Spec.Validate() runs pre-persist and enforces:
  • start must exist in states.
  • State keys match ^[a-zA-Z][a-zA-Z0-9_]{0,63}$.
  • Assignees match user:<positive-int> or role:<slug>. Nothing else reaches SQL.
  • kind: "approve" requires a non-empty assignee and at least one choice; every choice must have a mapping in on.
  • kind: "end" must not declare any on transitions.
  • Every on[event] target must be a state that exists.
  • timeout_sec must be non-negative.
On top of 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 in core/approvals/handler.go:
  • system — passthrough. Empty trigger emits success; otherwise echoes the trigger. Useful as a routing hub.
  • approve — spawns a approval_tasks row on entry and parks. On resume the trigger (the choice string, or "timeout") is emitted as the event. Task deadline_at inherits state.timeout_sec.
  • end — terminal. The runner finalizes the run to state='done' and expires open tasks in the same tx.
Plugin authors add new kinds via Engine.RegisterHandler — see §6.

3. HTTP surface

Six endpoints in core/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 on http://localhost:8000 and $TOKEN holds an admin API token minted via POST /api/login.

Step 1: Register the spec

Response 201:

Step 2: Start a run against doc 17

Response 201:
The dispatcher is nudged so the first 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

Returns {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:
Returns 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:
For each due row it enqueues 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 by Kind(), so built-ins can be overridden.
  • Return Event="" with no Task to park indefinitely — the run waits for an external Resolve / Cancel / timeout.

AssigneeResolver

The second seam. Default resolver accepts user:N and rejects role:* with ErrRoleUnresolved. Enterprise RBAC swaps in via engine.SetAssigneeResolver(r) before boot.
The runner calls 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 on workflow.advance.transition, workflow.sweep.fired, workflow.resolve, workflow.advance.park when 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 with from == to == current_state, reason in payload_json, expires open tasks, finalizes to state='cancelled'. Cancelling a terminal run returns 409 terminal.
  • Idempotent advanceAdvance on a non-running run logs workflow.advance.skip_terminal and returns nil, so redelivered outbox jobs after finalization are safe.

Not in scope (deferred)

  • Parallel / fan-out states. One current_state cursor 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:X resolution + fair dispatch. AssigneeResolver is reserved for enterprise; core ships only user:N. Resolve returns []int64 for eventual round-robin, but the runner doesn’t consume the list yet.
  • Merging approval_tasks into /api/tasks/. Today the outbox and approval_tasks are surfaced separately; the mobile-compat single-poll story lands with Phase 4.