Skip to content

Explicit state machines and guarded transitions

One table decides which moves exist; the guarded write decides who makes each one.

Predict first

Before you read: predict

At 11:00, ExamHall's two pods both run autoSubmit for ECON-201 attempt a-1, whose row in attempts says active:

Both pods' SELECTs run before either pod's UPDATE.

What happens?

Code for this question
// attempts/machine.tsexport type Status = 'active' | 'submitted' | 'graded' | 'voided';export type AttemptEvent = 'submit' | 'grade' | 'void'; export class IllegalTransition extends Error {} const TRANSITIONS: Record<Status, Partial<Record<AttemptEvent, Status>>> = {  active: { submit: 'submitted', void: 'voided' },  submitted: { grade: 'graded', void: 'voided' },  graded: {},  voided: {},}; export function next(from: Status, event: AttemptEvent): Status {  const to = TRANSITIONS[from][event];  if (!to) throw new IllegalTransition(`${event} in ${from}`);  return to;} // attempts/auto-submit.tsimport { next, Status } from './machine.js'; export interface Db { query(sql: string, params: unknown[]): Promise<{ rows: any[]; rowCount: number }>; } export async function autoSubmit(db: Db, attemptId: string, enqueueGrading: (id: string) => void) {  const { rows } = await db.query('SELECT status FROM attempts WHERE id = $1', [attemptId]);  const to = next(rows[0].status as Status, 'submit');  const r = await db.query('UPDATE attempts SET status = $2 WHERE id = $1', [attemptId, to]);  if (r.rowCount === 1) enqueueGrading(attemptId);}
Pick one

The 30-second version

List the states and, in one table, the moves allowed from each; every endpoint and job asks that table, so an illegal move is refused in one place. A check in code judges a status read a moment ago, so the write must carry the check too. Its row count says whether this caller made the move.

One table decides which moves exist; the guarded write decides who makes each one.

How it works

Step 1 of 12

A closed list of states. Name every state an entity can be in. A status any code may set to anything is not a machine.

Analogy: A planning office's application register (local planning)

At the planning office In code
the office rulebook lists which stamp may follow which: received, validated, approved or refused, withdrawn the transition table (step 2)
the counter, the case officer and the committee clerk all stamp by the same rulebook one function judges every path (step 3)
a clerk's slip says "validated" from this morning; by the time she stamps, a colleague may already have approved it a check in code reads the past (step 6)
the register line takes a stamp only if it still shows what the slip says, and the clerk writes down whether her stamp went on the guarded write and its count (steps 7–8)
a clerk whose stamp did not go on looks at the register line again before deciding what to do after a 0, re-read (step 10)

In real terms: the rulebook says which stamps exist; the register line decides whose stamp counts.

Breaks down when: a clerk can see a colleague at the register and wait; two callers never see each other, only the row.

Contrasting case: in an actor system such as Akka, each entity's messages are handled one at a time by its own actor, so its state checks need no guard in the write; the queue serialises the moves instead.

Examples, run for real

4.1 ExamHall EXH-4070: the console's own check

The proctor console's "Submit for candidate" button had its own if (status !== 'graded') before an UPDATE … WHERE id = $1. A proctor pressed it on attempt a-2, already submitted, and grading ran again. MR !4370 sends the console, the timer and the candidate's button through one next() and one guarded UPDATE whose $from comes from the table.

4.1 EXH-4070 as shipped (console's own if, UPDATE … WHERE id = $1): a-2 (submitted) -> grading queued 1 time
4.1 MR !4370 console path through transition(): next('submitted', 'submit') throws IllegalTransition · grading queued 0 times
4.1 MR !4370 the guarded UPDATE alone (status = ANY('{active}')): a-2 -> UPDATE 0 · grading queued 0 times

4.2 Two pods, one move

After MR !4370, both pods run the guarded autoSubmit for a-1 at 11:00, both SELECTs first, as in the hook.

4.2 MR !4370 two pods, both SELECTs first: pod A UPDATE 1 · pod B UPDATE 0 · grading queued 1 time · a-1 submitted

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A chat app moderates messages with this table:

The team tries three versions of moderate.ts:

// moderation/moderate.ts (version 1)
import { Action, Db, TRANSITIONS } from './machine.js';

export async function moderate(db: Db, id: string, action: Action, notify: (id: string) => void) {
  const { to } = TRANSITIONS[action];
  const r = await db.query('UPDATE messages SET status = $2 WHERE id = $1 AND status <> $2', [id, to]);
  if (r.rowCount === 1) notify(id);
}
// moderation/moderate.ts (version 2)
import { Action, Db, TRANSITIONS } from './machine.js';

export async function moderate(db: Db, id: string, action: Action, notify: (id: string) => void) {
  const { from, to } = TRANSITIONS[action];
  const r = await db.query('UPDATE messages SET status = $2 WHERE id = $1 AND status = ANY($3)', [id, to, from]);
  if (r.rowCount === 1) notify(id);
}
// moderation/moderate.ts (version 3)
import { Action, Db, TRANSITIONS } from './machine.js';

export async function moderate(db: Db, id: string, action: Action, notify: (id: string) => void) {
  const { from: [prev], to } = TRANSITIONS[action];
  const r = await db.query('UPDATE messages SET status = $2 WHERE id = $1 AND status = $3', [id, to, prev]);
  if (r.rowCount === 1) notify(id);
}

Message m-1 is visible, m-2 hidden and m-3 removed. Two moderators each remove m-1, one after the other; then one moderator removes m-2, and one restores m-3. The goal: m-1 and m-2 end removed with one notice each, and m-3 stays removed with no notice.

Which version meets it?

Code for this question
// moderation/machine.tsexport type Status = 'visible' | 'hidden' | 'removed';export type Action = 'hide' | 'restore' | 'remove'; export const TRANSITIONS: Record<Action, { from: Status[]; to: Status }> = {  hide: { from: ['visible'], to: 'hidden' },  restore: { from: ['hidden'], to: 'visible' },  remove: { from: ['visible', 'hidden'], to: 'removed' },}; export interface Db { query(sql: string, params: unknown[]): Promise<{ rows: any[]; rowCount: number }>; }
Pick one

Question 2 of 6

The chat app's remove handler, on the P1 table (TRANSITIONS.remove is { from: ['visible', 'hidden'], to: 'removed' }):

Message m-7 is visible. Moderator A's remove(db, 'm-7', notify) returns; then moderator B's remove(db, 'm-7', notify) runs.

What does B's call do?

Code for this question
// moderation/remove.tsimport { Db, TRANSITIONS } from './machine.js'; export class ConflictError extends Error {} export async function remove(db: Db, id: string, notify: (id: string) => void) {  const { from, to } = TRANSITIONS.remove;  for (let attempt = 1; attempt <= 3; attempt++) {    const r = await db.query('UPDATE messages SET status = $2 WHERE id = $1 AND status = ANY($3)', [id, to, from]);    if (r.rowCount === 1) {      notify(id);      return `removed on attempt ${attempt}`;    }  }  throw new ConflictError(`${id} not removed`);}
Pick one

Question 3 of 6

A chat app models rooms with GoF state objects:

run.ts runs once.

What happens?

Code for this question
// rooms/states.tsexport class IllegalTransition extends Error {} export interface Room { name: string; state: RoomState; log: string[]; } export abstract class RoomState {  join(room: Room, user: string): void { throw new IllegalTransition(`join while ${this.constructor.name}`); }  archive(room: Room): void { throw new IllegalTransition(`archive while ${this.constructor.name}`); }} export class Open extends RoomState {  private joins = 0;  join(room: Room, user: string) {    this.joins += 1;    room.log.push(`${user} is member ${this.joins}`);  }  archive(room: Room) { room.state = ARCHIVED; }} export class Archived extends RoomState {} export const OPEN = new Open();export const ARCHIVED = new Archived(); // rooms/run.tsimport { OPEN, Room } from './states.js'; const general: Room = { name: 'general', state: OPEN, log: [] };const random: Room = { name: 'random', state: OPEN, log: [] }; general.state.join(general, 'ana');general.state.archive(general);random.state.join(random, 'cy'); export const randomLog = random.log;
Pick one

Question 4 of 6

Each moderator acts on a report with this function, on the P1 table:

Message m-5 is hidden after a spam report. Ama opens that report and calls act(db, 'm-5', 'remove'); her SELECT runs first. Before her UPDATE runs, Ben's act(db, 'm-5', 'restore') and then Cleo's act(db, 'm-5', 'hide'), for a new report, each run to the end.

What do the three calls return, and how does m-5 end?

Code for this question
// moderation/act.tsimport { Action, Db, Status, TRANSITIONS } from './machine.js'; export async function act(db: Db, id: string, action: Action): Promise<number> {  const { rows } = await db.query('SELECT status FROM messages WHERE id = $1', [id]);  const seen = rows[0].status as Status;  if (!TRANSITIONS[action].from.includes(seen)) return 0;  const r = await db.query('UPDATE messages SET status = $2 WHERE id = $1 AND status = $3', [id, TRANSITIONS[action].to, seen]);  return r.rowCount;}
Pick one

Question 5 of 6

A streaming catalogue publishes videos in bulk:

Videos v-1 and v-3 are ready; v-2 is transcoding. A job calls publishAll(db, ['v-1', 'v-2', 'v-3']) outside any transaction, and it throws PartialPublish.

What do the three rows say afterwards?

Code for this question
// catalog/publish.tsexport interface Db { query(sql: string, params: unknown[]): Promise<{ rows: any[]; rowCount: number }>; } export class PartialPublish extends Error {} export async function publishAll(db: Db, ids: string[]) {  const r = await db.query("UPDATE videos SET status = 'published' WHERE id = ANY($1) AND status = 'ready'", [ids]);  if (r.rowCount !== ids.length) throw new PartialPublish(`${r.rowCount} of ${ids.length}`);}
Pick one

Question 6 of 6

In a mock interview you said two gate readers can scan ticket t-1 at once, and only the reader whose guarded UPDATE changes 1 row admits. The interviewer asks: "What does the other reader show? And ticket t-2 was refunded: what does a reader show for it?" Three candidates answer with code:

t-1 is issued and t-2 refunded. Reader 1 scans t-1, then reader 2 scans t-1, then a reader scans t-2. The interviewer wants the second t-1 screen to show the holder is already inside, and the t-2 screen to stop the holder and say why.

Which answer survives?

Code for this question
// gates/scan.tsexport interface Db { query(sql: string, params: unknown[]): Promise<{ rows: any[]; rowCount: number }>; } export async function scanOnce(db: Db, id: string): Promise<number> {  const r = await db.query("UPDATE tickets SET status = 'scanned' WHERE id = $1 AND status = 'issued'", [id]);  return r.rowCount;} // gates/answer-a.tsimport { Db, scanOnce } from './scan.js'; export async function screen(db: Db, id: string) {  return (await scanOnce(db, id)) === 1 ? 'admit' : 'already admitted';} // gates/answer-b.tsimport { Db, scanOnce } from './scan.js'; export async function screen(db: Db, id: string) {  return (await scanOnce(db, id)) === 1 ? 'admit' : 'stop: illegal transition';} // gates/answer-c.tsimport { Db, scanOnce } from './scan.js'; export async function screen(db: Db, id: string) {  if ((await scanOnce(db, id)) === 1) return 'admit';  const { rows } = await db.query('SELECT status FROM tickets WHERE id = $1', [id]);  return rows[0].status === 'scanned' ? 'already admitted' : `stop: ${rows[0].status}`;}
Pick one

Go deeper

Pro tips
  1. Test the table and the guard against each other. For every state and every event, next() must throw exactly when the guarded UPDATE changes 0 rows on a row in that state. When: the table or a guard changes (steps 2, 7). Verify (RAN, tips.mjs): with $from read from the table, all 12 (state, event) pairs of the attempt machine agreed; with the console's hand-written status <> 'graded' guard used for every event, 5 of 12 disagreed.
  2. Log the count of every guarded transition, and act only on a 1. A 0 is the database telling you this caller did not make the move. When: any job or handler that moves a status and then has a side effect (step 8).
Source
  • PostgreSQL documentation, UPDATE (PostgreSQL 18), section "Outputs": https://www.postgresql.org/docs/18/sql-update.html · fetched 2026-10-13; the same passage in the PostgreSQL 17 page https://www.postgresql.org/docs/17/sql-update.html. The curriculum row's second source. "The count is the number of rows updated, including matched rows whose values did not change. … If count is 0, no rows were updated by the query (this is not considered an error)." (steps 8, 10). Valid as of 2026-10-13.
  • PostgreSQL documentation, Transaction Isolation, "Read Committed Isolation Level" (PostgreSQL 18): https://www.postgresql.org/docs/18/transaction-iso.html · fetched 2026-10-13. Not named by the row; teaching only (step 9, cited, never served). "the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). … If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition." Valid as of 2026-10-13.
  • Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), State: the row's first source, a book; it keys nothing. Steps 4–5 rest on P3's run.

Checked against the tools and docs as of 2026-10-13.