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 anExceptionEnvelopebut 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 fromRESOLVEDbecause 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.
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
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
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
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.
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.
Related guides in this collection
- Encoding Provisional-Credit State Transitions in Python — the guarded
INVESTIGATING → PROVISIONAL_CREDIT_ISSUEDedge with Reg E eligibility and a reversal deadline. - SLA Timers and Exception Aging for Reconciliation Breaks — banking-day aging, breach detection, and escalation that keep items from stalling in a state.
- Fraud-Pattern Detection Signals in Payment Reconciliation — the scoring stage that runs before an item reaches
PENDING_REVIEW. - Routing Reconciliation Exceptions into Prioritized Work Queues — where triaged breaks are assigned to analysts under the segregation-of-duties guard.
- Exception Routing & Fraud-Pattern Detection — the parent reference covering the full journey of a break after the matcher gives up.