Modeling the Exception Lifecycle as a Finite-State Machine

A reconciliation break lives for hours or weeks between the moment the matcher gives up and the moment a dollar amount is finally settled or written off, and everything that happens in that interval is a compliance event. Within the broader exception routing and fraud-pattern detection framework, this guide is about the one structure that keeps that interval governable: an explicit finite-state machine that names every status a break can hold, every legal transition between them, a guard on each transition, and an append-only log of who moved the item and when. The alternative — a free-text status column somebody updates by hand — is how disputes get provisional credit twice, how items rot in states nobody defined, and how an examiner question becomes an archaeology project.

The machine is not decoration over a workflow; it is the workflow, and it is simultaneously the spine of the immutable audit trail the whole exception subsystem depends on. Two of its transitions carry particular regulatory weight and get their own detailed treatment: the guarded move into provisional credit, worked through in encoding provisional-credit state transitions, and the clock that limits how long an item may sit before it breaches, covered in SLA timers and exception aging. Everything below is the scaffolding those two transitions hang on.

The States, Precisely

A state machine is only as trustworthy as its state definitions are exact. Vague states — "open", "in progress" — are indistinguishable in an audit and let two different real situations share one label. The exception lifecycle uses a closed, non-overlapping set:

  • NEW — the break exists as an ExceptionEnvelope but no human or rule has touched it. It has been created by the matcher and nothing else. No clock the consumer can see has necessarily started yet.
  • PENDING_REVIEW — the item has been triaged (fraud-scored, sanctions-screened, routed to a queue) and is waiting for an analyst to pick it up. It is assigned but not yet actively worked.
  • INVESTIGATING — an attributable analyst has taken the item and is actively working it. This is the state a Reg E investigation clock runs against.
  • PROVISIONAL_CREDIT_ISSUED — for a consumer dispute the institution could not resolve inside the initial window, provisional credit has been posted to the account and a reversal deadline has been stamped. This is a distinct state precisely because it carries an obligation the others do not: money has moved that may have to be reversed.
  • RESOLVED — a terminal state. The break has a final disposition: matched to a late-arriving record, corrected, provisional credit finalized, or the consumer determined to be in error and credit reversed. No further transitions are legal without an explicit reopen.
  • WRITTEN_OFF — a terminal state for an uncollectable or immaterial break that the institution absorbs. Distinct from RESOLVED because the accounting treatment and the audit expectations differ.

The two terminal states matter as much as the active ones. A machine without terminals lets items drift forever; a machine that conflates "resolved correctly" with "written off" hides a material distinction an examiner cares about.

Allowed Transitions and Guards

The power of the model is in what it refuses. The transition table below is exhaustive — any pair not listed is illegal by construction, and the apply_transition function raises rather than silently allowing it. Each transition carries a guard: a predicate that must hold before the move is permitted.

From To Guard
NEW PENDING_REVIEW triage complete: fraud-scored, sanctions-cleared, queued
NEW WRITTEN_OFF amount below materiality floor; auto-write-off approved
PENDING_REVIEW INVESTIGATING assigned analyst present and distinct from payment approver
INVESTIGATING PROVISIONAL_CREDIT_ISSUED consumer account, Reg E error, initial window exceeded
INVESTIGATING RESOLVED final disposition recorded with evidence reference
INVESTIGATING WRITTEN_OFF deemed uncollectable; write-off authority approved
PROVISIONAL_CREDIT_ISSUED RESOLVED investigation closed; credit finalized or reversed within deadline
RESOLVED INVESTIGATING explicit reopen with supervisor actor and reason

Two guards deserve emphasis. The PENDING_REVIEW → INVESTIGATING guard enforces segregation of duties: the actor taking the item must not be the person who created or approved the underlying payment. That is a control, not a convenience — it belongs in the transition guard so it cannot be skipped under load. The INVESTIGATING → PROVISIONAL_CREDIT_ISSUED guard is where Regulation E becomes code; its full logic lives in the provisional-credit transition guide.

Exception lifecycle finite-state machine Six states connected by guarded transitions. NEW transitions to PENDING_REVIEW when triage is complete, or directly to WRITTEN_OFF when the amount is below the materiality floor. PENDING_REVIEW transitions to INVESTIGATING when an assigned analyst distinct from the payment approver takes it. INVESTIGATING has three outgoing transitions: to PROVISIONAL_CREDIT_ISSUED when the item is a consumer Reg E error and the initial window is exceeded, to RESOLVED when a final disposition is recorded, or to WRITTEN_OFF when deemed uncollectable. PROVISIONAL_CREDIT_ISSUED transitions to RESOLVED when the investigation closes and credit is finalized or reversed within the deadline. RESOLVED can transition back to INVESTIGATING only via an explicit supervisor reopen. RESOLVED and WRITTEN_OFF are terminal states. Every transition writes an append-only log entry with actor and timestamp. Guarded transitions — any pair not drawn is illegal NEW PENDING_REVIEW INVESTIGATING PROVISIONAL_CREDIT _ISSUED RESOLVED WRITTEN_OFF triage done analyst ≠ approver consumer · Reg E · window exceeded final disposition credit finalized/reversed uncollectable below floor reopen (supervisor)
The exception lifecycle as a guarded finite-state machine: two terminal states, one supervised reopen path, and a guard on every edge — any transition not drawn here is rejected by construction.

The Append-Only Transition Log

A state machine that mutates a single status field in place is worthless for audit: it records where an item is, never how it got there. The lifecycle model instead treats the current state as a projection of an append-only transition log. Every accepted transition writes an immutable record — the from-state, the to-state, the attributable actor, a UTC timestamp, the guard that fired, and a free-text reason — and the current state is simply the to-state of the latest entry. Nothing is ever updated or deleted.

That inversion is what makes the model examiner-ready. Reconstructing "why did this item receive provisional credit on the eleventh business day" is a log replay, not a guess. It also makes concurrent edits detectable: because the current state is derived, an apply_transition that reads a stale current state and tries to append an illegal follow-on transition is caught by the guard, and an optimistic-concurrency version check on the log tail rejects the losing writer instead of silently clobbering the winner.

Phase-by-Phase Implementation

The implementation has three parts: an enum of states, a transition table encoding the legal moves, and a guarded apply_transition that is the single doorway through which state ever changes.

Phase 1 — States and the transition table

python
from __future__ import annotations

from enum import Enum


class ExceptionState(str, Enum):
    NEW = "NEW"
    PENDING_REVIEW = "PENDING_REVIEW"
    INVESTIGATING = "INVESTIGATING"
    PROVISIONAL_CREDIT_ISSUED = "PROVISIONAL_CREDIT_ISSUED"
    RESOLVED = "RESOLVED"
    WRITTEN_OFF = "WRITTEN_OFF"


TERMINAL: frozenset[ExceptionState] = frozenset(
    {ExceptionState.RESOLVED, ExceptionState.WRITTEN_OFF}
)

# Every legal (from -> {to}) edge. Absence means illegal.
ALLOWED: dict[ExceptionState, frozenset[ExceptionState]] = {
    ExceptionState.NEW: frozenset(
        {ExceptionState.PENDING_REVIEW, ExceptionState.WRITTEN_OFF}
    ),
    ExceptionState.PENDING_REVIEW: frozenset({ExceptionState.INVESTIGATING}),
    ExceptionState.INVESTIGATING: frozenset(
        {
            ExceptionState.PROVISIONAL_CREDIT_ISSUED,
            ExceptionState.RESOLVED,
            ExceptionState.WRITTEN_OFF,
        }
    ),
    ExceptionState.PROVISIONAL_CREDIT_ISSUED: frozenset({ExceptionState.RESOLVED}),
    ExceptionState.RESOLVED: frozenset({ExceptionState.INVESTIGATING}),  # reopen only
    ExceptionState.WRITTEN_OFF: frozenset(),
}

Phase 2 — The transition record and guards

python
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Optional


@dataclass(frozen=True, slots=True)
class Transition:
    """One immutable, append-only lifecycle event."""
    exception_id: str
    from_state: ExceptionState
    to_state: ExceptionState
    actor_id: str                       # never blank; segregation of duties
    at: datetime                        # timezone-aware UTC
    guard: str
    reason: str


# A guard receives the item context and returns True to permit the move.
Guard = Callable[[dict[str, object]], bool]


def segregation_ok(ctx: dict[str, object]) -> bool:
    """The analyst taking the item must differ from the payment approver."""
    return ctx.get("actor_id") not in (
        ctx.get("payment_approver_id"),
        ctx.get("payment_creator_id"),
    )


GUARDS: dict[tuple[ExceptionState, ExceptionState], Guard] = {
    (ExceptionState.PENDING_REVIEW, ExceptionState.INVESTIGATING): segregation_ok,
    # PROVISIONAL_CREDIT_ISSUED guard is defined in the provisional-credit guide.
}

Phase 3 — The single guarded doorway

python
class IllegalTransition(Exception):
    """Raised when a transition is not in ALLOWED or a guard rejects it."""


def apply_transition(
    current: ExceptionState,
    target: ExceptionState,
    ctx: dict[str, object],
    log: list[Transition],
) -> Transition:
    """Validate and append a transition. The ONLY way state changes.

    Raises IllegalTransition if the edge is not permitted or its guard fails.
    Requires a non-empty actor_id so no state ever changes anonymously.
    """
    if current in TERMINAL and target != ExceptionState.INVESTIGATING:
        raise IllegalTransition(f"{current} is terminal")
    if target not in ALLOWED.get(current, frozenset()):
        raise IllegalTransition(f"{current} -> {target} is not allowed")

    actor_id = ctx.get("actor_id")
    if not actor_id:
        raise IllegalTransition("actor_id is required for every transition")

    guard = GUARDS.get((current, target))
    if guard is not None and not guard(ctx):
        raise IllegalTransition(f"guard rejected {current} -> {target}")

    event = Transition(
        exception_id=str(ctx["exception_id"]),
        from_state=current,
        to_state=target,
        actor_id=str(actor_id),
        at=datetime.now(timezone.utc),
        guard=guard.__name__ if guard else "none",
        reason=str(ctx.get("reason", "")),
    )
    log.append(event)          # append-only; current state = log[-1].to_state
    return event

Every path to a state change funnels through apply_transition. There is no setter on a status field, no direct SQL UPDATE, no way to reach a state except by appending a validated transition. That single-doorway property is what makes the guarantees hold under a real codebase with many contributors.

Failure Modes

Failure scenario Root cause Mitigation
Illegal transition slips through State mutated directly via SQL/setter, bypassing apply_transition Make the log the source of truth; forbid a mutable status column; route every change through the one function
Lost SLA timer on transition Deadline computed but not persisted with the transition Stamp the reversal/breach deadline inside the transition record itself, not a side table that can drift
Concurrent edits clobber each other Two analysts read the same current state and both append Optimistic-concurrency check on the log tail version; loser retries against fresh state
State change with no actor Automated job or import writes a transition anonymously Require non-empty actor_id; give system actors explicit service identities, never blank
Item stuck in INVESTIGATING forever No terminal reachable and no aging clock Pair the machine with the SLA aging timer so breaches escalate automatically

Compliance & Auditability

The lifecycle model is where three regulatory regimes land as concrete code obligations. Regulation E (12 CFR 1005.11) governs the INVESTIGATING → PROVISIONAL_CREDIT_ISSUED edge: for a consumer electronic-fund-transfer error the institution cannot resolve within ten business days of the consumer's notice, provisional credit is generally required, and the transition guard must compute that deadline from the notice date. UCC Article 4A governs wire disputes, which are not Reg E consumer errors and follow a different resolution and finality regime — the machine keeps them distinct by routing commercial wire breaks away from the provisional-credit path entirely. Sarbanes-Oxley and FFIEC examination standards require that the controls be demonstrable: the append-only transition log, with an attributable actor and UTC timestamp on every edge, is the evidence, and it must be written to tamper-evident storage as described in the immutable audit trails reference. The append-only property is not a nice-to-have; a state model that overwrites its own history cannot satisfy an examiner asking to reconstruct a decision.

Testing & Verification

The highest-value tests assert that illegal moves fail. A state machine is defined as much by its rejections as its acceptances, so the test suite enumerates them explicitly.

python
import pytest


def test_illegal_transition_raises():
    log: list[Transition] = []
    ctx = {"exception_id": "EX-1", "actor_id": "an-42"}
    # RESOLVED is terminal; RESOLVED -> PENDING_REVIEW is not an allowed edge.
    with pytest.raises(IllegalTransition):
        apply_transition(
            ExceptionState.RESOLVED, ExceptionState.PENDING_REVIEW, ctx, log
        )


def test_transition_requires_actor():
    log: list[Transition] = []
    ctx = {"exception_id": "EX-2", "actor_id": ""}   # blank actor
    with pytest.raises(IllegalTransition):
        apply_transition(
            ExceptionState.NEW, ExceptionState.PENDING_REVIEW, ctx, log
        )


def test_segregation_of_duties_enforced():
    log: list[Transition] = []
    ctx = {
        "exception_id": "EX-3",
        "actor_id": "an-9",
        "payment_approver_id": "an-9",   # same person -> must reject
    }
    with pytest.raises(IllegalTransition):
        apply_transition(
            ExceptionState.PENDING_REVIEW, ExceptionState.INVESTIGATING, ctx, log
        )


def test_happy_path_appends_and_projects_state():
    log: list[Transition] = []
    ctx = {"exception_id": "EX-4", "actor_id": "sys-triage", "reason": "triaged"}
    ev = apply_transition(
        ExceptionState.NEW, ExceptionState.PENDING_REVIEW, ctx, log
    )
    assert ev.to_state is ExceptionState.PENDING_REVIEW
    assert log[-1].to_state is ExceptionState.PENDING_REVIEW  # state is a projection

Frequently Asked Questions

Why model exceptions as a state machine instead of a status column?

A status column records only where an item is now; it cannot answer how it got there, who moved it, or when — the exact questions a Reg E or SOX examiner asks. A finite-state machine with an append-only transition log makes the current state a projection of an immutable history, so every decision is reconstructable, illegal moves are impossible by construction, and segregation of duties can be enforced inside the transition guard rather than hoped for in a runbook.

What belongs in a transition guard versus in application code?

Anything that must never be skipped belongs in the guard: segregation of duties, Reg E eligibility, materiality floors. Guards run inside the single apply_transition doorway, so they cannot be bypassed by a different code path. Application code handles what happens around a transition — notifying an analyst, updating a dashboard — but the decision to permit the move itself is always a guard, because that is the code an examiner will scrutinize.

How do you handle two analysts editing the same exception at once?

Because the current state is derived from the log tail rather than stored mutably, concurrent edits are detectable with an optimistic-concurrency check: each apply_transition records the log version it read, and the second writer to commit against a stale version is rejected and retries against fresh state. This turns a silent clobber into an explicit, recoverable conflict, which matters because two analysts issuing provisional credit on the same dispute would otherwise double-credit the account.

Can a RESOLVED exception be reopened?

Yes, but only through the single explicit RESOLVED → INVESTIGATING edge, and only with a supervisor-level actor and a recorded reason. Reopens are deliberately narrow because a terminal state that anyone can undo is not terminal. The append-only log preserves the original resolution alongside the reopen, so the audit trail shows both the initial disposition and the justification for revisiting it.

Where does the provisional-credit deadline get stored?

Inside the transition record for the INVESTIGATING → PROVISIONAL_CREDIT_ISSUED edge, not a separate table that can drift out of sync. Stamping the reversal deadline on the transition itself means the obligation travels with the immutable event that created it, so the aging and breach machinery reads a single source of truth. The full computation of that deadline is covered in the provisional-credit state transitions guide.