Enforcing a job state machine in Firestore security rules
Worrachatr Phoonbamphen · September 2026
A marketplace job moves through a few states: open, accepted, completed, cancelled. If the client app decides the transitions, anyone with a browser console can skip them. Firestore security rules can enforce the machine themselves, with no server code, if each transition is written as a small closed contract.
The pattern
Each allowed transition is one branch of allow update, and every branch answers four questions: who may do it, from which state, to which state, and which fields may change.
// an approved provider claims an open job
resource.data.status == 'open'
&& resource.data.customerId != request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'providerId'])
&& request.resource.data.status == 'accepted'
&& request.resource.data.providerId == request.auth.uid
&& providerApproved(request.auth.uid)
resource.data is the stored document and request.resource.data is what it would become. Comparing the two with diff().affectedKeys().hasOnly(...) is what stops a request from changing the status and, in the same write, the title or the price.
What this rejects
- Skipping a state: a customer marking their own job
completed. - The wrong actor: a different provider finishing someone else's job.
- Reopening: nothing leaves
completed, because no branch starts there. - Smuggled fields: extra changes riding along with a legal transition.
The race that rules cannot close
Two providers can claim the same open job at the same moment. Each write is judged against the document as it is stored, and Firestore commits only the first; the second then finds the status already accepted and fails. The invariant holds, but the client has to expect that failure and show "someone else got it". Rules cannot serialise anything. They judge one write at a time.
Testing it
A rule with only "allowed" tests can be wide open. For every branch I test the legal transition and its neighbours: wrong actor, wrong starting state, an extra field. Writing the suite caught a hole in my own first draft: the provider-profile read rule did not require sign-in, so anonymous callers could read approved profiles. The test failed and I fixed the rule, not the test.