Task lifecycle
Every task in AfriAnnotate carries a state that tracks where it is in the labelling workflow. Three states:
States are managed by the FSM (finite-state machine) module under
label_studio/fsm/ and are feature-flag gated — when the flag is off
no state records are written. They live in their own TaskState
records keyed by UUID7 (time-ordered) — distinct from the Task row,
so state history is preserved.
The three states
CREATED
The task has been imported but has no annotation yet. Default state for every new task. Opening the task, saving a draft, or commenting does not change it.
COMPLETED
Set when the first annotation is submitted on the task. This is
independent of the project's maximum annotations per task: the
FSM state says "someone has submitted", while is_labeled (the flag
the labelling stream and Data Manager use) says "the task reached its
annotation target".
IN_PROGRESS
Entered only when annotations are deleted: after a deletion the
task's state is re-derived from is_labeled — if the task no longer
meets its annotation target it moves COMPLETED → IN_PROGRESS (for
example, all annotations removed, or one of three removed on a
three-annotator project). The next submitted annotation moves it back
to COMPLETED.
For tasks that predate the FSM, state is inferred on first touch:
is_labeled → COMPLETED, some annotations but not labelled →
IN_PROGRESS, none → CREATED.
The annotation cap is enforced at write time
Rotation stops serving a full task, but the cap is also a wall at
annotation create: once a task has its target number of
annotations, a direct submit — Data Manager quick-view, API client,
replayed offline queue — is rejected with 403
task_fully_annotated ("This task already has its target number of
annotations — move on to the next one"). Two exemptions: a user still
holding the task's lock may submit (rotation served them the task
before it filled; their in-flight work is never lost to the race), and
managers, staff, and reviewers may annotate past the cap —
adjudication adds annotations beyond it by design. Skips don't count
against the cap and are always allowed.
Annotation state
Annotations have their own state but in the current schema it's just
CREATED — annotations are append-only artifacts. Submissions,
updates, and deletes happen on the annotation itself; the
AnnotationState table exists for history tracking, not gating.
Project state
Projects also carry state, derived from their tasks:
- CREATED — no task has been completed yet
- IN_PROGRESS — at least one task is COMPLETED
- COMPLETED — every task is COMPLETED; drops back to IN_PROGRESS when any task leaves COMPLETED (e.g. annotations deleted)
Transitions are automatic. There is no archive action — pausing a project (unpublishing it) is a separate setting and does not touch FSM state.
Data Manager
The Data Manager does not show FSM state as a column. Use its real columns instead:
- Annotations = 0 — nothing submitted yet
- Completed (timestamp) is empty — the task has not reached its annotation target
- Cancelled > 0 — skipped at least once
Where state changes are recorded
Every transition lands in the TaskState / ProjectState tables with
the transition name, reason, and user — queryable through the FSM API.
Transitions do not write platform audit-log events or fire webhooks
on their own.
GET {{platformURL}}/api/fsm/entities/<task|annotation|project>/<id>/history
Returns the entity's state records newest-first, paginated
(page, page_size — default 100, max 1000): id (UUID7), state,
previous_state, transition_name, triggered_by (id, email),
reason, context_data, created_at. Filters: state,
previous_state, transition_name, triggered_by=<user id>,
created_at_from / created_at_to (ISO 8601); ordering=id / -id
only. Needs view permission on the entity; an entity the caller
can't see returns 404 (not 403), and results are limited to the caller's
active organisation. Annotator-role callers get the same list without
triggered_by. Unknown entity types are 404. With the FSM feature flag
off the list is simply empty — nothing was ever written.
Manual overrides
There is no UI for forcing a state. Registered manual transitions can be executed through the API:
POST {{platformURL}}/api/fsm/entities/<entity_type>/<entity_id>/transition/
with {"transition_name": "...", "transition_data": {...}} (data
optional). Only transitions that are not auto-triggered are accepted:
task_completed, task_in_progress, project_in_progress,
project_completed, project_in_progress_from_completed. The *_created
and annotation_updated transitions fire on model changes and return 400
here, as do unknown names and transitions whose own validation fails.
Needs change permission on the entity (404 when not visible).
Response: {"success": true, "new_state": ..., "state_record": {...}};
with the feature flag off the call still succeeds with new_state and
state_record null and writes nothing. Useful for unsticking state
after a workflow change.
The FSM framework (developer-facing)
The FSM module is reusable — you can define your own entity states + transitions if you're extending the platform:
- State models (
fsm/state_models.py):BaseState,TaskState,AnnotationState,ProjectState— UUID7-keyed state records - Transition system (
fsm/transitions.py): declarative pydantic-based transitions with validation - Registry (
fsm/registry.py): register entity types and transitions - TransitionExecutor (
fsm/transition_executor.py): runs the state change + post-transition hooks (e.g. task → project updates)
See label_studio/fsm/README.md in the repo for the developer guide.
What's next
- Data Manager → — filter on annotation counts and completion
- Labeling guide → — what happens while a task is being annotated
- Review workflow → — the reviewer's accept / reject path