SLA Timers and Exception Aging for Reconciliation Breaks
An exception that nobody looks at ages silently, and a silently aging exception is how a ten-business-day Reg E deadline quietly becomes an eleven-day breach. This page belongs to the exception lifecycle state machine cluster within the broader exception routing and fraud-pattern detection reference, and it isolates one concern the state machine depends on but does not itself provide: measuring how long a break has been alive on a banking-day calendar, sorting it into an aging bucket, detecting when it crosses an SLA boundary, and escalating before the boundary becomes a breach. Where the provisional-credit transition stamps a deadline, this is the machinery that watches it.
Aging is deceptively easy to get wrong because the intuitive implementation — subtract two datetime values — counts calendar time, and no regulatory clock in payments runs on calendar time. Weekends and Federal Reserve holidays do not consume a business day, a UTC-versus-local boundary can shift an item a full day, and a timer that is computed but never persisted resets every time the process restarts. Each of those is a real production breach vector, and the code below is built to resist all three.
Concept Spec: Age Is a Banking-Day Count
Let a break carry a creation instant (or, for a regulated clock, the consumer's notice date). Its age at evaluation time is not ; it is the number of banking days that fall in the half-open interval, excluding Saturdays, Sundays, and Fed holidays. Aging then maps that count onto ordered buckets:
The boundaries are business-driven: 0–1 day is within same/next-day expectation, 1–3 days covers normal settlement drift, 3–10 days is the Reg E investigation runway, and anything past 10 business days has crossed the initial statutory window. Computing is a bounded walk over at most calendar days, so each evaluation is in the span; batched across open exceptions it is , and in practice is capped by the oldest open item, which the escalation layer keeps small by design.
Full Annotated Implementation
The function below computes an exception's banking-day age and its SLA state against a per-rail, per-error SLA budget. It takes a timezone-aware creation instant, normalizes to a banking calendar in a fixed settlement timezone, and returns both the age and a breach classification ready to drive escalation.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from enum import Enum
from zoneinfo import ZoneInfo
SETTLEMENT_TZ = ZoneInfo("America/New_York") # Fed settlement clock, not UTC-naive
class AgeBucket(str, Enum):
FRESH = "FRESH" # 0-1 business days
AGING = "AGING" # 1-3 business days
STALE = "STALE" # 3-10 business days
BREACHED = "BREACHED" # > 10 business days
@dataclass(frozen=True, slots=True)
class SlaStatus:
business_age: int
bucket: AgeBucket
sla_budget_days: int
breached: bool
days_to_breach: int # negative once past the budget
def to_settlement_date(instant: datetime) -> date:
"""Project a timezone-aware instant onto the Fed settlement calendar date.
Rejecting naive datetimes here prevents the classic off-by-one where a
22:00 UTC create-time lands on the previous local banking day.
"""
if instant.tzinfo is None:
raise ValueError("instant must be timezone-aware")
return instant.astimezone(SETTLEMENT_TZ).date()
def business_age(start: date, today: date, holidays: frozenset[date]) -> int:
"""Banking days in (start, today], excluding weekends and Fed holidays."""
if today <= start:
return 0
count, cursor = 0, start
while cursor < today:
cursor += timedelta(days=1)
if cursor.weekday() < 5 and cursor not in holidays:
count += 1
return count
def bucket_for(age: int) -> AgeBucket:
if age <= 1:
return AgeBucket.FRESH
if age <= 3:
return AgeBucket.AGING
if age <= 10:
return AgeBucket.STALE
return AgeBucket.BREACHED
def evaluate_sla(
created_at: datetime,
now: datetime,
sla_budget_days: int,
holidays: frozenset[date],
) -> SlaStatus:
"""Return the banking-day age, aging bucket, and breach state of a break.
sla_budget_days is the per-rail, per-error budget (e.g. 10 for a Reg E
initial window, 2 for a same-day wire break). Breach is measured against
that budget, independent of the coarse display bucket.
"""
start = to_settlement_date(created_at)
today = to_settlement_date(now)
age = business_age(start, today, holidays)
return SlaStatus(
business_age=age,
bucket=bucket_for(age),
sla_budget_days=sla_budget_days,
breached=age > sla_budget_days,
days_to_breach=sla_budget_days - age,
)
Calibration Per Rail and Error Type
The display buckets are fixed, but the sla_budget_days that decides breach is a calibration surface tuned to the obligation behind each break:
- Same-day wires (Fedwire, CHIPS). Finality is immediate, so a break here is urgent: a budget of 1–2 business days is appropriate, and an unresolved same-day wire break should escalate hard well before a consumer ACH dispute does.
- Consumer ACH Reg E disputes. Budget the initial statutory window — 10 business days — so an item crossing into the
BREACHEDbucket coincides with the point at which provisional credit becomes mandatory. This is the case where the aging layer and the provisional-credit transition meet. - Commercial ACH and non-regulated breaks. These follow internal SLAs rather than a statutory clock; set the budget from the operations agreement (often 3–5 business days) and treat breach as an internal-escalation event rather than a regulatory one.
Store budgets in a version-controlled table keyed by (rail, error_class) so a change hot-reloads without a deploy, and never hardcode a single global budget — a same-day wire and a new-account dispute share no reasonable SLA.
Before / After Walkthrough
Take break EX-20260701-0093, an unresolved consumer ACH dispute created 2026-07-01 21:30 UTC with a 10-business-day budget, evaluated on 2026-07-16 14:00 UTC. Fed holidays in the span include Independence Day observed 2026-07-03.
Before (naive calendar subtraction). (2026-07-16) - (2026-07-01) yields 15 calendar days, which an unbucketed timer reads as long past due — and worse, the 21:30 UTC create-time is 17:30 local, still 07-01 in New York, but a UTC-naive .date() would have been fine here while a 23:30 UTC create-time would have wrongly rolled to 07-02, shifting the whole clock a day.
After (banking-day aware). to_settlement_date pins the start to 2026-07-01 and today to 2026-07-16; business_age walks the interval excluding two weekends and the 07-03 holiday, yielding 10 business days. Against the 10-day budget:
{
"business_age": 10,
"bucket": "STALE",
"sla_budget_days": 10,
"breached": false,
"days_to_breach": 0
}
The item is STALE with days_to_breach: 0 — on the very edge — so the escalation layer pages the supervisor today, before tomorrow's evaluation flips breached to true. A naive calendar timer would have either cried breach five days early (eroding trust in the alert) or, if tuned looser to compensate, missed the real edge entirely.
Failure Modes & Guardrails
- Weekend and holiday drift. Counting calendar days inflates age and fires false breach alerts, while a holiday calendar that is stale or missing the current year's Fed holidays under-counts and misses real ones. Load Fed holidays from an authoritative, annually refreshed source into the
holidaysset, and assert the set covers the evaluation window at startup. - Timezone boundary error. A create-timestamp compared across UTC and local can shift an item a full banking day at the 22:00–00:00 UTC edge. Reject naive datetimes at the boundary and project every instant onto one explicit settlement timezone before taking a date, as
to_settlement_datedoes. - Timer not persisted. If age is recomputed only in memory, a process restart or a missed scheduled run silently resets the clock and an item can slip its SLA between evaluations. Persist the creation instant and the computed breach deadline on the exception's immutable transition record, and run breach evaluation on a durable, idempotent schedule so a missed tick is caught on the next one rather than lost.
Frequently Asked Questions
Why not just subtract two timestamps to get exception age?
Because no payment SLA runs on calendar time. A break created Friday evening is not two days old on Sunday in any sense a regulator recognizes — weekends and Federal Reserve holidays do not consume business days. Subtracting timestamps over-counts age across every weekend and holiday, producing false breach alerts that train analysts to ignore the alerting entirely. Age must be a banking-day count on an explicit Fed holiday calendar.
How do the display buckets relate to the SLA breach budget?
They are independent on purpose. The 0-1d / 1-3d / 3-10d / >10d buckets are a coarse, fixed view for dashboards and triage ordering, while sla_budget_days is the precise, per-rail, per-error threshold that decides breach. A same-day wire with a 2-day budget can be BREACHED while still sitting in the AGING display bucket, and reading breach off the coarse bucket instead of the budget is a common source of missed same-day escalations.
What timezone should exception age be computed in?
One explicit settlement timezone — for U.S. rails, the Fed's Eastern settlement clock — applied consistently, never UTC-naive dates and never the server's local time. The implementation rejects naive datetimes and projects every instant onto America/New_York before taking a calendar date, which removes the off-by-one that appears when a late-evening UTC create-time straddles the local midnight boundary.
How should a breach actually escalate?
Escalation should be graduated and driven by days_to_breach, not a single alarm at the boundary. An item approaching its budget (one day out) pages the assigned analyst; an item at the boundary pages the supervisor; a breached item raises a tracked incident and, for a Reg E dispute, forces the provisional-credit path. Because evaluation runs on a durable idempotent schedule, the same item is re-checked every cycle, so an escalation that is missed once is retried rather than dropped.
Related guides in this collection
- Modeling the Exception Lifecycle as a Finite-State Machine — the parent cluster whose states this aging layer keeps from stalling.
- Encoding Provisional-Credit State Transitions in Python — the transition that stamps the reversal deadline this timer watches.
- Exception Routing & Fraud-Pattern Detection — the grandparent reference covering triage, scoring, and prioritized routing.
- Routing Reconciliation Exceptions into Prioritized Work Queues — where an item's age feeds the priority score that orders the queue.