Batch Approval UI Patterns for Reviewer-Facing Reconciliation Sign-Off
During financial close a single qualified approver may face thousands of open exceptions, and forcing them to click through each one individually is both a throughput bottleneck and a control risk — fatigue drives rubber-stamping. Batch approval UI patterns are the reviewer-facing surface that lets a qualified approver safely act on many exceptions at once: exceptions are grouped by a shared, explainable profile, each group collapses to one summary delta with one action, and every guardrail that governs a single sign-off is enforced across the whole group. This page describes the presentation and interaction layer that sits on top of the engine documented in Batch Approval Automation: where that page defines how sign-offs are executed and committed server-side, this one defines how a human is shown a batch, how the interface prevents an unsafe bulk action, and how each item in a bulk commit is still individually auditable. It is a direct extension of Exception Routing & Human-in-the-Loop Workflows.
Prerequisites: Upstream Pipeline State
The batch approval surface is a rendering and action layer over items that are already governed. It never invents grouping or authority; it consumes state that earlier stages produced. Before a group can be shown to a reviewer the following must hold:
- Each item is a settled review-queue entry. Every candidate carries a stable
UNDER_REVIEWstate, anidempotency_key, and a computed variance inDecimal, produced by the state machine in Manual Review Queue Design. The grid displays exceptions; it does not re-match or re-classify them. - A resolved materiality band and reviewer tier. The routing directive that assigns each item to an approval tier already exists, so the grid can refuse to place items from different authority bands into one actionable group.
- A canonical variance profile per item. Amount delta, counterparty identifier, currency, and a root-cause classification must be attached so grouping is deterministic rather than heuristic.
- A per-item version token. Every item exposes a monotonic
version(or rowxmin) captured at render time, so the interface can detect that the underlying record changed between display and commit.
If any of these is absent the item is rendered as ungroupable and can only be actioned individually, because collapsing an unclassified exception into a bulk delta would hide exactly the signal a reviewer needs.
Grouping Mechanism: Deterministic Clustering by Variance Profile
The core of a safe batch interface is that grouping is deterministic and explainable. A reviewer approving a group is implicitly asserting that every member shares the same disposition, so two items may only join a group when they are provably alike along the dimensions that matter for a sign-off. We compute a group_key as a hash over a fixed, ordered set of normalised fields — counterparty, currency, sign and magnitude band of the variance, and a root-cause hash — and items with identical keys form one group. Normalisation is the load-bearing step: a raw counterparty string, a currency code in mixed case, and an unrounded variance would each fracture otherwise-identical items into singleton groups, so every field is canonicalised before it enters the hash.
The grouping function is O(n) over the open exception set — one hash per item, one dictionary insertion — so the dominant cost is not clustering but the render of the collapsed summary rows. The important caveat is a financial one: the variance band must be coarse enough to be useful yet fine enough that a single summary delta is honest. Bucketing a £40 break and a £4,000 break into one group because they share a counterparty would let a reviewer approve a material item while looking at an immaterial summary. The interface therefore refuses to merge across a materiality boundary even when every other field matches; those items surface as separate groups.
Production-Grade Python: Grouping and an Atomic Bulk-Approve Handler
The grouping function hashes a fixed, ordered tuple of normalised fields so the same set of exceptions always yields the same groups, and the bulk-approve handler treats the entire group as one unit of work: it revalidates every version token, enforces segregation of duties, commits inside a single transaction, and writes one audit record per item carrying the trace_id, source_hash, and match_decision. A stale token, a materiality mismatch, or a self-approval fails the whole batch and rolls back, so a bulk action is never partially applied.
import hashlib
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable, Mapping, Sequence
logger = logging.getLogger("reconciliation.batch_approval")
MATERIALITY_CAP = Decimal("500.00") # per-item ceiling for bulk eligibility
MAX_BATCH_SIZE = 250 # hard guardrail on group cardinality
@dataclass(frozen=True)
class Exception_:
item_id: str
idempotency_key: str
counterparty: str
currency: str
variance_amount: Decimal
root_cause: str
match_decision: str # MATCHED | PARTIAL | UNMATCHED, from upstream
version: int # optimistic-lock token captured at render time
def _band(amount: Decimal) -> str:
# Coarse magnitude band keeps a summary delta honest without merging
# across a materiality boundary.
a = abs(amount)
sign = "N" if amount < 0 else "P"
if a < Decimal("10"):
return f"{sign}:<10"
if a < Decimal("100"):
return f"{sign}:<100"
return f"{sign}:>=100"
def group_key(exc: Exception_, fields: Sequence[str]) -> str:
# Deterministic hash over an ORDERED set of normalised fields. Identical
# keys => one actionable group; normalisation prevents spurious splits.
parts: list[str] = []
for f in fields:
if f == "counterparty":
parts.append(exc.counterparty.strip().upper())
elif f == "currency":
parts.append(exc.currency.strip().upper())
elif f == "variance_band":
parts.append(_band(exc.variance_amount))
elif f == "root_cause":
parts.append(exc.root_cause.strip().lower())
else:
raise ValueError(f"Unknown group field: {f}")
material = "|".join(parts)
return hashlib.sha256(material.encode("utf-8")).hexdigest()
def cluster_exceptions(
items: Iterable[Exception_], fields: Sequence[str]
) -> dict[str, list[Exception_]]:
groups: dict[str, list[Exception_]] = {}
for exc in items:
groups.setdefault(group_key(exc, fields), []).append(exc)
return groups
class BulkApprovalError(Exception):
def __init__(self, code: str, detail: str) -> None:
super().__init__(f"{code}: {detail}")
self.code = code
def approve_group(
conn, # DB-API connection (transactional)
group: Sequence[Exception_],
approver_id: str,
initiator_ids: Mapping[str, str], # item_id -> user who raised the adjustment
seen_versions: Mapping[str, int], # item_id -> version the UI displayed
*,
require_dual_control: bool = True,
) -> str:
trace_id = str(uuid.uuid4())
if not group:
raise BulkApprovalError("EMPTY_BATCH", "no items in group")
if len(group) > MAX_BATCH_SIZE:
raise BulkApprovalError("BATCH_TOO_LARGE", f"{len(group)} > {MAX_BATCH_SIZE}")
# Reject a group that spans a materiality boundary (MIXED_MATERIALITY).
bands = {_band(e.variance_amount) for e in group}
if len(bands) > 1:
raise BulkApprovalError("MIXED_MATERIALITY", f"bands={sorted(bands)}")
if any(abs(e.variance_amount) > MATERIALITY_CAP for e in group):
raise BulkApprovalError("MIXED_MATERIALITY", "item exceeds materiality cap")
total = Decimal("0.00")
cur = conn.cursor()
try:
for exc in group:
# Segregation of duties: an approver may never sign off their own break.
if require_dual_control and initiator_ids.get(exc.item_id) == approver_id:
raise BulkApprovalError("SOD_VIOLATION", f"{exc.item_id} self-approval")
# Optimistic concurrency: the row must still be at the version the
# reviewer saw. A conditional UPDATE returns 0 rows if it moved.
cur.execute(
"UPDATE review_item SET state = 'APPROVED', version = version + 1 "
"WHERE item_id = %s AND version = %s AND state = 'UNDER_REVIEW'",
(exc.item_id, seen_versions.get(exc.item_id, exc.version)),
)
if cur.rowcount != 1:
raise BulkApprovalError("STALE_BATCH", f"{exc.item_id} moved under review")
amt = exc.variance_amount.quantize(Decimal("0.01"), ROUND_HALF_UP)
total += amt
source_hash = hashlib.sha256(
f"{exc.item_id}:{exc.idempotency_key}:{approver_id}:{amt}".encode()
).hexdigest()
# Per-item audit even though the action was bulk.
cur.execute(
"INSERT INTO approval_audit "
"(item_id, trace_id, source_hash, match_decision, approver, amount, ts) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
(exc.item_id, trace_id, source_hash, exc.match_decision,
approver_id, str(amt), datetime.now(timezone.utc)),
)
logger.info(
"batch_item_approved",
extra={
"trace_id": trace_id,
"source_hash": source_hash,
"match_decision": exc.match_decision,
"item_id": exc.item_id,
"approver": approver_id,
"amount": str(amt),
},
)
conn.commit()
except Exception:
conn.rollback() # PARTIAL_COMMIT is impossible: all-or-nothing.
raise
logger.info(
"batch_approved",
extra={"trace_id": trace_id, "count": len(group), "total": str(total)},
)
return trace_id
The UPDATE ... WHERE version = %s clause is the whole optimistic-concurrency contract: if any item advanced between the render that built the summary row and the commit, its conditional update matches zero rows, the handler raises STALE_BATCH, and the surrounding transaction rolls back every sibling. Because the audit insert and the state update share one transaction, a PARTIAL_COMMIT — some items approved, some not — cannot occur.
Configuration Rules & Threshold Calibration
Every parameter that governs a batch action is version-controlled and timestamped; changing one is itself an auditable event, because widening a bulk boundary directly widens the blast radius of a single reviewer click. Treat the defaults as conservative and loosen them only with evidence from replayed close cycles.
| Parameter | Type | Default | Valid range | Tuning guidance |
|---|---|---|---|---|
max_batch_size |
int |
250 |
1 – 2000 |
Caps items per bulk action; smaller for high-materiality tiers to keep review meaningful. |
materiality_cap |
Decimal |
500.00 |
0 – ledger cap |
Any item above this is excluded from bulk and forced to individual sign-off. |
group_key_fields |
list[str] |
counterparty, currency, variance_band, root_cause |
subset of profile fields | Fewer fields = larger groups; never drop the field that carries the disposition. |
require_dual_control |
bool |
true |
— | Enforces segregation of duties in the UI; blocks self-approval per item. |
optimistic_lock |
enum |
VERSION |
VERSION | XMIN | OFF |
OFF is prohibited for financial data; XMIN uses the Postgres system column. |
Calibration is empirical: replay a representative close window, measure the realised error rate of bulk-approved groups against individually reviewed ones at each group_key_fields configuration, and stop widening groups at the point where bulk error rate begins to diverge from individual error rate. Record the chosen envelope alongside the reconciliation run so an auditor can reconstruct why a given group was actionable in bulk.
Multi-Dimensional Validation
A batch action is safe only when several independent constraints hold simultaneously, so the handler validates on the logical intersection of orthogonal gates rather than a single check. Any one failing fails the batch:
- Structural homogeneity — every member shares one
group_key, and the magnitude band is single-valued, so the summary delta faithfully represents each item. - Freshness — every version token still matches the underlying row; a mismatch means the reviewer acted on a stale rendering and the batch is rejected as
STALE_BATCH. - Authority — the approver holds the tier the group’s materiality requires, and is distinct from every item’s initiator, satisfying segregation of duties.
Because these gates are independent, partial satisfaction is diagnostic rather than approximate: a group can be structurally homogeneous and fresh yet fail authority, and that precise combination tells the interface to route the group to a higher tier instead of silently downgrading the check. The interface surfaces which gate failed and on which items, so a reviewer never receives an opaque rejection.
Async / High-Throughput Patterns
At close scale the grid may need to cluster tens of thousands of open exceptions and revalidate them the instant before commit. Clustering is CPU-bound hashing and runs off the event loop with asyncio.to_thread, while the version-token revalidation that guards each commit runs as concurrent, bounded database reads so a large group does not serialise into one slow round trip.
import asyncio
from typing import Sequence
async def build_groups(
items: Sequence[Exception_], fields: Sequence[str]
) -> dict[str, list[Exception_]]:
# Deterministic hashing is pure CPU; keep it off the event loop.
return await asyncio.to_thread(cluster_exceptions, items, fields)
async def revalidate_versions(
pool, group: Sequence[Exception_], concurrency: int = 16
) -> list[str]:
sem = asyncio.Semaphore(concurrency) # bound DB fan-out; natural backpressure
async def check(exc: Exception_) -> str | None:
async with sem:
async with pool.acquire() as conn:
current = await conn.fetchval(
"SELECT version FROM review_item WHERE item_id = $1", exc.item_id
)
return exc.item_id if current != exc.version else None
results = await asyncio.gather(*(check(e) for e in group))
return [item_id for item_id in results if item_id is not None]
A pre-commit revalidation pass narrows — but does not replace — the transactional guard: it lets the interface warn a reviewer that a group went stale before they click, while the conditional UPDATE inside approve_group remains the authoritative check at commit time. Grouping and clustering across counterparties is exactly the workload that Batch Approval Automation drives programmatically; this surface simply gives a human the same guarantees interactively.
Failure Modes & Remediation
Every failure is emitted with the batch trace_id and each affected item_id so it can be triaged from the audit stream. The unifying principle is that no bulk action ever half-applies: a batch either commits in full or rolls back in full.
| Code | Root cause | Remediation |
|---|---|---|
STALE_BATCH |
An item’s version token changed between render and commit | Reject the batch; re-render the group at current versions and let the reviewer re-confirm. |
MIXED_MATERIALITY |
A group spans a magnitude band or an item exceeds materiality_cap |
Split the group at the materiality boundary; force the material items to individual sign-off. |
PARTIAL_COMMIT |
A mid-batch error left some items updated (must never persist) | The single transaction rolls back all siblings; re-queue the group unchanged. |
SOD_VIOLATION |
The approver initiated one of the items in the group | Block the batch; route those items to an orthogonal approval group. |
BATCH_TOO_LARGE |
Group cardinality exceeds max_batch_size |
Paginate the group into capped sub-batches, each independently confirmed. |
When a batch is rejected the interface re-renders the affected group at its current state rather than retrying blindly, because a bulk action taken against a stale summary is precisely the control failure these codes exist to prevent. Items that repeatedly fail bulk eligibility fall back to the individual path in Manual Review Queue Design.
Compliance & Audit Trail Requirements
Batch approval carries a specific regulatory hazard: a single click can dispose of many general-ledger items, so the audit trail must make each disposition individually accountable even though the human acted once. The surface enforces this through three mechanisms, and every approved item emits a record carrying its trace_id, source_hash, and match_decision for SOX Section 404 traceability:
- Per-item audit of a bulk action. One
approval_auditrow is written per item, all sharing the batchtrace_idbut each carrying its ownsource_hashand amount, so an auditor can reconstruct exactly which items a given click approved and confirm none was smuggled into the group. - Segregation of duties in the interface. Dual control is enforced before the action is offered, not merely at commit: an item whose initiator equals the current approver is rendered as non-actionable and raises
SOD_VIOLATIONif forced, satisfying PCI-DSS Requirement 10 and GAAP control expectations. - Materiality documentation. The
group_key_fields,materiality_cap, andmax_batch_sizeactive at execution time are version-controlled, so the exact bulk-eligibility envelope for any historical batch is reconstructable on demand.
Upon commit the surface publishes reconciliation deltas downstream with idempotent upserts keyed on each item’s idempotency_key, so a retried batch never double-posts. For the end-to-end construction of the actionable grid itself — pagination, selection ergonomics, and the guarded confirm control — see Building a Bulk-Approve Exception Grid, and for the unattended equivalent that signs off groups without a human in the loop, see Automating Batch Reconciliation Sign-Offs.
By treating the batch approval surface as a guarded, deterministic projection of already-governed exceptions, FinOps and accounting-engineering teams give reviewers close-period throughput without surrendering the per-item accountability, segregation of duties, and atomicity that an audit-grade reconciliation pipeline demands.