Core concepts · Gates

Gates and review

Steps do the work; gates decide whether it was good enough. Most gates are automatic and invisible — you only meet them when one rejects something. A few are human, and those are why a run can end in review rather than completed.

Steps do, gates judge

A gate is not a step. It runs no model and produces no output of its own. It inspects what the previous step produced and declares which step id it unlocks — that is the whole mechanism.

a gate, as declared in a workflowjson
{
  "id": "gate-text",
  "name": "OCR text gate",
  "type": "quality",
  "inputs": ["audited"],
  "outputs": ["lettering-checked"],
  "unlocks": ["step-4"]
}
Gate typeWho decidesEffect
qualityThe engine, automaticallyPasses silently, or fails the step and triggers escalation.
approvalA personParks the run in review until someone decides.
hitlA personSame as approval; the two names are interchangeable in practice.
Only approval gates produce a review
If a workflow declares no approval or hitl gate, its runs go straight from running to completed. Most public workflows are in that category — they are gated on quality, not on a human.

When an automatic gate rejects

A failed quality gate does not necessarily fail the run. The engine has an escalation ladder: it rewinds the cursor, rebinds the role that produced the bad output to a different tool, and re-runs that segment. Only when the ladder is exhausted does the run fail.

WHEN A VERDICT STEP REJECTSattempt 1Renderstep-1Remove bgfal/birefnetAlpha audithalo rejectednot reachedrewind + rebind the anchor roleattempt 2Remove bgesy/difference-matteAlpha auditpassClassifyonly when the ladder is exhausted does the run fail
The escalation ladderThe failed attempt survives as a step record carrying escalatedToRung and escalatedToTool. Work already paid for is never re-bought.

When it does fail, you get the gate’s actual reasoning rather than a code. This is a real failure from a text gate catching lettering the image model invented:

GET /v1/runs/{run_id}json
{
  "status": "failed",
  "error": "step 'step-4': OCR text gate rejected the render — verdict: {
    \"foundText\": \"BRIEF, STRATEGY, CREATIVE\",
    \"pass\": false,
    \"reason\": \"The image contains multiple legible text elements, which
                 violates the 'none' text policy requiring zero readable text.\"
  }"
}

The QA record

Whatever happens, the checks that ran are recorded on the artifact. Each carries a status of pass, fail, or pending.

artifact.qajson
"qa": {
  "status": "pending_review",
  "checks": [
    { "id": "text-gate", "label": "Text gate", "status": "pass", "detail": "" }
  ]
}
A check only exists if something measured it
There is no “assumed pass”. If a check is absent from qa.checks, nothing verified that property — which is different from it having been verified and passed. When you care, look for the check rather than the absence of a failure.

The review queue

Runs sitting in review make up the queue. It is a view, not a separate table — which is why there is nothing to keep in sync.

EndpointDoes
GET /v1/queueEverything waiting on a person.
GET /v1/queue/countJust the number, for a badge.
POST /v1/queue/{run_id}/decisionDecide one run.
POST /v1/queue/decisionsDecide many at once.
approving a runbash
curl -X POST https://api.esy.com/v1/queue/run-a1b2c3d4/decision \
  -H "Authorization: Bearer $ESY_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "decision": "approve", "note": "Colours match the pack." }'

decision is approve, reject, or request_changes, and each maps to the matching terminal run status. Every decision is written to a durable ledger with who decided, when, and any note — so review is auditable rather than a state flip.

What approval will refuse

Two rules protect the queue from rubber-stamping:

  • You cannot approve over a failed check. If any entry in qa.checks is fail, approval is rejected. Fix the work or re-run it.
  • You cannot approve while a check is still pending. A verification that has not finished is not a verification.

Typed holds

Sometimes a run is held not because the work is wrong but because something is missing — most often a classification the model could not confidently assign. That is a typed hold, and it declares exactly which fields a reviewer must supply.

releasing a typed holdbash
curl -X POST https://api.esy.com/v1/queue/run-a1b2c3d4/decision \
  -H "Authorization: Bearer $ESY_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "decision": "approve",
    "patch": { "category": "woodland-animals" }
  }'

The patch must match the hold exactly — no more fields, no fewer. Supplying extra keys is refused, and so is the placeholder "uncategorized": a hold exists precisely because that answer is not acceptable.

Humans approve at gates; machines preserve between them
Between two gates the engine is not allowed to quietly improve things — it preserves what it was given. A decision belongs at a gate, where it is recorded and attributable. That is what makes an approved artifact mean something later.
In short
  • Gates judge and unlock; they never execute work. Only approval/hitl gates involve a person.
  • A failed quality gate first tries to recover by rewinding and rebinding, and records every escalation.
  • Approval is refused over a failed or pending check, and a typed hold must be patched exactly.