Task board state machine: a status is earned, not set

state-machine
Mechanism lab

Task board state machine: a status is earned, not set

The starter treats status like a plain field — apply() jumps straight to the command's target without checking where the task is now, so it'll happily make illegal jumps (e.g. complete a task that's still CREATED, or restart a COMPLETED one). Your job: only allow a command that's legal from the current status (else return "409"), and append an audit row only on success.

résumé · An ops task board is the same transaction-integrity shape as Opendoor's offer lifecycle and the manufacturing/financial state work at SK AX — status-change-as-business-event with validation + audit.

You'll predict the failure first, then write the fix and watch your own code drive the behavior.

Understand it step by step (한국어로 이해 → 영어로 말하기)

집(매물)에 보낸 제안서가 단계를 밟는다: 만듦 → 보냄 → 수락 → 종료. 단계를 그냥 "값"으로 두면 아무 단계에서 아무 데로나 막 뛴다. 그러면 안 된다.
  1. 1

    제안서는 정해진 길을 따라간다: 만듦(CREATED) → 보냄(SENT) → 수락(ACCEPTED) → 종료(CLOSED). 중간에 취소되거나 만료될 수도 있다.

    // CREATED -> SENT -> ACCEPTED -> CLOSED (or CANCELLED / EXPIRED)
    String status = "CREATED";
    attempt(m, "send");   // CREATED -> SENT
    attempt(m, "accept"); // SENT -> ACCEPTED
    attempt(m, "close");  // ACCEPTED -> CLOSED
    🗣 영어로 말해

    An offer moves through fixed stages: created, sent, accepted, then closed.

    checking microphone…

  2. 2

    처음 짠 코드는 단계를 그냥 글자 하나로 본다. "종료로 바꿔" 하면 지금 어디인지 안 보고 그냥 종료로 적어버린다.

    ⚖️ Trade-off: 지금 상태를 안 보면, 이미 끝난 제안서를 또 보내는 말도 안 되는 일이 그냥 통과된다.

    // correct: check the CURRENT status before moving, don't just overwrite
    String next = table.getOrDefault(status, Map.of()).get(command);
    if (next == null) return "409";   // illegal from current status
    status = next;
    🗣 영어로 말해

    The buggy code just overwrites the status without checking where the offer is now.

    checking microphone…

  3. 3

    그래서 막 뛴다. 예를 들어 이미 종료(CLOSED)된 제안서를 또 보내려(SENT) 해도 막힘 없이 된다. 현실엔 없는 일이 데이터엔 생긴다.

    ⚖️ Trade-off: 단계 건너뛰기가 허용되면 데이터가 현실과 어긋나고, 나중에 아무도 믿을 수 없게 된다.

    🔧 도구:enum status (그냥 값으로만 쓰면 X)

    // terminals accept nothing -> no illegal jump out of CLOSED
    // CLOSED / EXPIRED / CANCELLED have no entry in the table
    if (!table.containsKey(status)) return "409";
    🗣 영어로 말해

    So it makes illegal jumps, like sending an offer that is already closed.

    checking microphone…

  4. 4

    고치는 핵심: "지금 단계에서 이 명령이 되나?"를 표로 정해둔다. (지금 상태 + 명령) → 다음 상태. 표에 있으면 진행, 없으면 거절.

    ⚖️ Trade-off: 허용되는 길을 일일이 표로 적어둬야 한다. 코드가 조금 늘어난다.

    ✅ Fix: 단계가 몇 개 안 되니 표는 작다. 이 작은 표 하나가 "불가능한 상태"를 아예 못 만들게 막아준다 → 충분히 남는 장사.

    🔧 도구:transition table: Map<(status, command), nextStatus>FSM (finite state machine)

    // allowed moves keyed on (current status, command) -> next status
    Map<String, Map<String, String>> table = Map.of(
        "CREATED",  Map.of("send", "SENT", "cancel", "CANCELLED"),
        "SENT",     Map.of("accept", "ACCEPTED", "expire", "EXPIRED", "cancel", "CANCELLED"),
        "ACCEPTED", Map.of("close", "CLOSED", "cancel", "CANCELLED"));
    🗣 영어로 말해

    I model the allowed moves as a table keyed on the current status and the command.

    checking microphone…

  5. 5

    안 되는 명령이 오면 조용히 무시하지 않는다. "지금 상태랑 충돌이라 안 됨"이라고 409로 분명히 알려준다.

    ⚖️ Trade-off: 그냥 무시하면 부른 쪽은 됐는지 안 됐는지 몰라서 더 위험하다.

    ✅ Fix: 409로 명확히 거절하면, 부른 쪽이 "아, 이미 상태가 바뀌었구나" 알고 다시 읽어볼 수 있다.

    🔧 도구:HTTP 409 Conflict

    String next = table.getOrDefault(status, Map.of()).get(command);
    if (next == null) return "409";  // conflict with current state, not a silent move
    🗣 영어로 말해

    An illegal command is a 409 conflict, not a silent move.

    checking microphone…

  6. 6

    성공해서 단계가 바뀔 때마다, 누가(actor) 왜(reason) 바꿨는지 기록 한 줄을 같은 트랜잭션 안에서 남긴다. 상태 변경과 기록이 같이 되거나 같이 안 된다.

    ⚖️ Trade-off: 성공할 때마다 기록 한 줄씩 쌓이니 저장 공간을 조금 쓴다.

    ✅ Fix: 나중에 "이 제안서 왜 이렇게 됐지?"를 추적할 수 있다. 돈이 오가는 일이라 이 추적 기록이 꼭 필요하다.

    🔧 도구:audit tableDB transaction (상태+감사기록 원자적)

    // on a committed transition, write one audit row in the same transaction
    status = next;
    audit.add(command + " by " + actor + " reason=" + reason);
    return "OK";
    🗣 영어로 말해

    Every successful transition writes one audit row in the same transaction, with who and why.

    checking microphone…

  7. 7

    실무라면 두 개를 더 붙인다: 한 집엔 살아있는 제안서가 하나만 있게 막는 규칙, 그리고 같은 요청이 두 번 와도 한 번만 처리되게 하는 키.

    ⚖️ Trade-off: 네트워크가 흔들리면 같은 "수락" 요청이 두 번 도착해, 두 번 처리될 위험이 있다.

    ✅ Fix: 멱등키(idempotency key)를 붙이면, 같은 요청은 처음 결과를 그대로 다시 돌려주고 다시 적용하진 않는다.

    🔧 도구:unique constraint (one active offer per home)idempotency key

    // production: enforce one active offer per home + dedup retries
    UNIQUE (home_id) WHERE status NOT IN ('CLOSED','EXPIRED','CANCELLED');
    if (seenKeys.containsKey(idempotencyKey)) return seenKeys.get(idempotencyKey);
    🗣 영어로 말해

    In production I would add one-active-offer-per-home and an idempotency key.

    checking microphone…

7단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

How it evolves — toy → production

The problem

A status field is updated from many places. With no guard, illegal jumps slip in (CLOSED -> SENT) and corrupt the lifecycle.

  1. 1Status string + scattered ifstoy — illegal jumps leak
    order.status = "SHIPPED";   // set from anywhere
    // nothing stops CLOSED -> SHIPPED, or skipping a required step

    Use when: Never for a real lifecycle with rules.

    Cost / limit: Illegal transitions corrupt state; the logic is scattered everywhere.

  2. 2Explicit transition guardproduction default
    static final Map<Status, Set<Status>> NEXT = Map.of(
        CREATED, Set.of(SENT, CANCELLED),
        SENT,    Set.of(ACCEPTED, EXPIRED));
    
    void to(Status next) {
        if (!NEXT.getOrDefault(status, Set.of()).contains(next))
            throw new IllegalStateException(status + " -> " + next);
        status = next;
    }

    Use when: Any entity with a defined lifecycle. Most cases.

    Cost / limit: One table to keep correct; every transition must go through it.

  3. 3State machine + audit logneed history / who-did-what
    // the transition table is the guard; an audit row is the trail
    // (entity_id, from, to, actor, at, reason) on every accepted transition

    Use when: Compliance, debugging, answering 'why is this CLOSED?'.

    Cost / limit: An audit table to write and store on every transition.

  4. 4Event-sourcedfull rebuildable history
    // store EVENTS, not the current status:
    //   state = fold(events)   [Created, Sent, Accepted] -> ACCEPTED
    // rebuild any past state by replaying the log

    Use when: You need full history and to reconstruct any prior state.

    Cost / limit: Reads need fold/snapshots; a bigger mental model.

Know the ladder — start at the simplest that meets the need, and say what you'd reach for next and why.

Explain it in English

Say it out loud in your own words — hit these terms — then check against the gold answer.

Say it in your own words — but hit these: state machine · 409 · audit · task board

checking microphone…