Work order state machine
Every work order in Knurl has a state, and every state change is an event recorded in the append-only event log. The log is the source of truth — the current state is just a cache of the latest event. This page documents every state and every event so you know exactly what each button does.
States
A work order can be in one of these 10 states:
reported— A technician or guest scanned a QR and reported an issue. Awaiting manager triage.draft— A manager started writing a work order but hasn't submitted it yet. Not visible to technicians.open— Triaged and ready to assign.assigned— A technician has been assigned. Waiting for them to start work.in_progress— The assignee has started work.on_hold— Work paused. Reason recorded.pending_review— Technician marked complete and attached photo proof. Awaiting manager approval.completed— Manager approved. The fix is done.closed— Final. Archived for reporting.cancelled— Terminated before completion.
Events
13 events move work orders between states.
Triage
created→reported|draft|open. Technicians can only create asreported; managers can create at any of the three start states.triaged:reported→open. Manager confirms it's real work.rejected_report:reported→cancelled. Manager dismisses (duplicate, not an issue, etc.).submitted:draft→open. Manager finishes drafting and submits.
Assignment
assigned:open→assigned. Requires anew_assignee_id.reassigned:assigned|in_progress→assigned. Same.
Execution (assignee only)
These transitions require the actor to bethe assignee. A manager who isn't the assignee cannot start, hold, resume, or complete the work — they reassign to themselves first if they want to do it.
started:assigned→in_progress.held:in_progress→on_hold. Requires areason(free text).resumed:on_hold→in_progress.completed:in_progress→pending_review. Requires at least one photo and notes. Without those, the API rejects the event.
Review & close
approved:pending_review→completed. Manager accepts the work.rejected:pending_review→in_progress. Manager kicks it back with a comment.closed:completed→closed. Archives the work order for reporting. Often automated.
Cancellation
cancelled: any non-terminal state →cancelled. Terminal. Used when the underlying work is no longer needed.
Why a transition can fail
When the API or mobile app rejects an event, it returns a machine reason and an HTTP status:
illegal_transition→ 409. You tried to move to a state that isn't reachable from where you are. (E.g. trying tostarta work order that's already in progress.)not_assignee→ 403. You tried to fire an assignee-only event but you're not the assignee.forbidden→ 403. Your role doesn't permit this action. (E.g. a technician trying to triage.)asset_not_found→ 404. The asset this work order references no longer exists.not_found→ 404. The work order itself doesn't exist or is in a different facility.photo_required→ 400.completedfired without any photo IDs.notes_required→ 400.completedfired without notes.reason_required→ 400.heldorrejectedfired without a reason.
What technicians see
The mobile app mirrors the same state machine. If a technician tries to complete a work order without photos while offline, the completion is queued but rejected at sync time — and the rejected-op card surfaces the reason so they can attach photos and try again. Nothing is lost.
Why append-only
Every state change is an event row in work_order_events, timestamped, attributed to the actor. The currentstatus on the work order is a projection of the latest event. This means:
- The full lifecycle is a permanent audit trail — never edited, never deleted.
- Reports project over events, not over a snapshot — so MTTR and throughput are always consistent with what actually happened.
- Offline conflict resolution is deterministic — events have server-side ordering, so two technicians' edits resolve predictably.
9e39bf7)