ADR-0013: Event-Based API for Task Status Transitions

Status

Accepted

Tags

fieldforce, state-machine, api, patch, events, transitions

Decision

Fieldforce task status changes are triggered by sending a named event (start, submit, approve, reject, resubmit, cancel) in the PATCH body — not a target status string. The server enforces all guards (role, current state, required fields) via the state machine before accepting any transition. The event key and field-update keys are mutually exclusive in the same PATCH body.

Why

A target-status API (PATCH { status: "completed" }) lets clients drive any transition by guessing the right status string. Guards in the state machine become unreachable because the handler must re-implement role and state checks before calling the machine — or skip them and let bugs through. Separate endpoints per transition proliferate routes without adding expressiveness. Rejected alternatives:
  • Target-status API (PATCH { status: "in_progress" }): Business rules can be bypassed. Every consumer needs to know which status strings are valid from which state. Rejected.
  • Separate endpoints per transition (POST /tasks/:id/approve): Multiplies routes. A single PATCH with an event discriminator is the idiomatic REST pattern for state machines. Rejected.

How it works

The PATCH body has two mutually exclusive shapes:
Transition:    { "event": "reject", "review_note": "Missing photos" }
Field update:  { "priority": "high", "due_date": "2026-06-01", "assigned_to": ["uid1"] }
Both present → 400 Bad Request
Valid events:
EventFromToGuard
startpendingin_progressrole ∈
submitin_progresscompletedrole = field_team
approvecompletedapprovedrole ∈
rejectcompletedneeds_revisionrole ∈ + review_note present
resubmitneeds_revisioncompletedrole = field_team
cancelany non-terminalcancelledrole ∈
Handler dispatches to TaskService.TransitionTask for event bodies and TaskService.UpdateTask for field-update bodies.

Rules for agents

  • PATCH handler MUST reject with 400 if both event and any field-update key are present in the same body
  • Never accept a status field in the PATCH body to change task state — use event only
  • review_note is required when event = "reject" — validate at handler level before calling the state machine
  • Do not add role checks in the HTTP handler that duplicate guard logic already in the state machine transitions

Bad pattern (do not generate)

// Accepting a target status — bypasses state machine guards entirely
func (h *TaskHandler) PatchTask(c echo.Context) error {
    var body struct{ Status string `json:"status"` }
    c.Bind(&body)
    task.Status = body.Status // wrong — no guard, no role check
    return h.repo.Update(ctx, task)
}
// Client sending target status instead of event
await fetch(`/tasks/${id}`, {
  method: "PATCH",
  body: JSON.stringify({ status: "approved" }), // wrong — use event
});

Good pattern

// Handler dispatches on event vs field-update
func (h *TaskHandler) PatchTask(c echo.Context) error {
    var body TaskPatchBody
    if err := c.Bind(&body); err != nil { return err }
    if body.Event != "" && hasFieldUpdates(body) {
        return echo.NewHTTPError(400, "event and field updates are mutually exclusive")
    }
    if body.Event != "" {
        return h.handleTransition(c, body)
    }
    return h.handleFieldUpdate(c, body)
}
// Client sends a named event — not a target status
await updateTask({ event: "approve" });
await updateTask({ event: "reject", review_note: "Missing site photos" });

// Field update uses separate body shape
await updateTask({ priority: "high", due_date: "2026-06-01" });