LLMs  

Deterministic Guardrails for Digital Banking Onboarding RAG

The High Cost of Unconstrained Agents in Regulated Finance

In digital banking, customer onboarding is a high-stakes workflow. A new user trying to open an account expects a seamless experience, but they also trigger strict KYC (Know Your Customer) and AML (Anti-Money Laundering) checks. When multi-agent RAG systems are deployed here without rigorous constraints, three failure modes become regulatory liabilities:

  1. Infinite Loops: An identity verification agent rejects a document due to glare, asks the user to retake it, receives the same image, rejects it again, and enters a 20-turn loop that violates fair lending response-time SLAs.

  2. Dead Ends: A compliance check fails silently because the conditional edge lacks a fallback path, leaving the applicant staring at a spinner while their session times out—creating a compliance gap where no decision was ever recorded.

  3. Unnecessary Chatter: Three agents negotiate over whether a utility bill satisfies address proof, adding 8 seconds of latency and $0.04 of token cost per onboarding attempt, directly impacting conversion rates.

These aren’t engineering annoyances; they are conversion killers and audit failures. This article demonstrates battle-tested patterns for preventing all three, implemented in a production-grade LangGraph multi-agent RAG system for digital banking customer onboarding with full state management and persistent memory.

Real-Time Use Case: NeoBank Instant Account Opening

The Scenario

A popular digital bank (“NovaBank”) offers <5-minute account opening. The AI onboarding assistant must handle:

  • Document collection and validation (ID, selfie, proof of address)

  • KYC/AML screening against sanctions lists and PEP databases

  • Risk tier classification (low/medium/high) based on jurisdiction, occupation, and transaction intent

  • Regulatory disclosure delivery and e-signature capture

  • Human escalation for edge cases

The system integrates five live services: OCR/document validation API, sanctions screening API, core banking ledger, CRM, and compliance policy engine. Every interaction is audited for regulatory examination.

Why Naive Multi-Agent Fails in Onboarding

Failure ModeBusiness ImpactRoot Cause
Loop: Doc reject → re-upload → same rejectApplicant abandons; 12% drop-off rateNo max-attempt counter; no alternative path
Dead End: Sanctions API timeout → no responseApplication stuck; SLA breach; no audit recordMissing error-handling edges
Chatter: KYC ↔ Compliance ↔ Docs agents debating sufficiency45s avg latency vs. 15s targetOver-decomposition; no routing pre-filter
State Drift: Agent uses expired risk tier from prior stepWrong disclosures shown; regulatory violationNo state freshness validation
Repeated Questions: Agent asks for SSN already in stateUser frustration; trust erosionNo state-read-before-ask guard

Architecture: Constrained Onboarding Graph with Guardrails

395

Implementation

Step 1: State Design with Anti-Pattern Prevention Built In

State is your primary control surface. Every field prevents a specific failure mode.

from typing import Annotated, List, Dict, Any, Optional, Literal
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from datetime import datetime, timedelta
import operator

class DocumentAttempt(TypedDict):
    """Tracks each document submission attempt for loop prevention."""
    doc_type: str  # "government_id", "selfie", "proof_of_address"
    attempt_number: int
    status: Literal["accepted", "rejected", "pending"]
    rejection_reason: Optional[str]
    timestamp: datetime

class OnboardingState(TypedDict):
    """
    State with built-in guardrails against loops, dead ends, and chatter.
    Every field serves a specific anti-pattern prevention purpose.
    """
    
    # Conversation history
    messages: Annotated[list, add_messages]
    
    # Immutable applicant context (set once, never overwritten)
    applicant_context: Dict[str, Any]  
    # {"application_id": ..., "email": ..., "jurisdiction": ..., "product_type": ...}
    
    # Classified current step (set by router, NEVER re-classified mid-flow)
    current_step: Optional[Literal[
        "doc_collection", "kyc_screening", "risk_classification", 
        "disclosure_delivery", "e_signature", "complete", "escalated"
    ]]
    
    # Document tracking with attempt counters (LOOP PREVENTION)
    document_attempts: Dict[str, DocumentAttempt]
    max_doc_attempts: int  # Hard ceiling per document type
    
    # Execution metadata
    execution_trace: Annotated[List[Dict[str, Any]], operator.add]
    max_global_iterations: int
    current_iteration: int
    
    # Data freshness tracking (STALENESS PREVENTION)
    data_freshness: Dict[str, datetime]
    max_data_age_seconds: int
    
    # Collected verified data (typed, not free-form)
    verified_documents: Dict[str, Any]  # {"government_id": {...}, "selfie": {...}}
    kyc_result: Optional[Dict[str, Any]]
    risk_tier: Optional[Literal["low", "medium", "high"]]
    disclosures_accepted: bool
    signature_captured: bool
    
    # Error recovery state (DEAD END PREVENTION)
    error_count: int
    last_error: Optional[str]
    recovery_attempts: int
    max_recovery_attempts: int
    
    # Final output
    onboarding_status: Optional[Literal["approved", "rejected", "pending_review", "abandoned"]]
    status_reason: Optional[str]
    
    # Audit trail (REGULATORY REQUIREMENT)
    audit_trail: Annotated[List[Dict[str, Any]], operator.add]
    
    # Session metadata
    session_id: str
    application_id: str
    processing_start_time: datetime

🔑 Key Insight: current_step is set once by the router and never re-evaluated during execution. This single design decision eliminates the most common source of infinite loops: agents disagreeing about what step the user is on and re-routing endlessly.

Step 2: Intent Router — Eliminate Chatter at Entry

The router classifies the user’s current need deterministically before any specialist agent runs.

from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

class StepClassification(BaseModel):
    step: Literal[
        "doc_collection", "kyc_screening", "risk_classification",
        "disclosure_delivery", "e_signature", "clarification_needed"
    ]
    confidence: float = Field(ge=0.0, le=1.0)
    extracted_data: Dict[str, Any] = Field(default_factory=dict)
    reasoning: str

router_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_structured_output(StepClassification)

ROUTER_PROMPT = """You are NovaBank's onboarding intent classifier. Determine the CURRENT onboarding step.

Available steps:
- doc_collection: User needs to submit/re-submit ID, selfie, or proof of address
- kyc_screening: Documents accepted; ready for sanctions/PEP screening
- risk_classification: KYC passed; assign risk tier based on profile
- disclosure_delivery: Risk tier assigned; deliver required regulatory disclosures
- e_signature: Disclosures accepted; capture electronic signature
- clarification_needed: User's message is ambiguous or missing required info

RULES:
1. Check completed_steps in context FIRST. Never route to a completed step.
2. If documents are pending, ALWAYS route to doc_collection regardless of user question.
3. Be aggressive about doc_collection routing—incomplete docs block everything else.
4. Only classify as clarification_needed if truly ambiguous."""

async def router_node(state: OnboardingState) -> dict:
    """
    Single-pass step classification. Sets current_step ONCE.
    Prevents chatter from agents re-negotiating workflow position.
    """
    applicant = state.get("applicant_context", {})
    completed = {
        "docs_complete": len(state.get("verified_documents", {})) >= 3,
        "kyc_complete": state.get("kyc_result") is not None,
        "risk_assigned": state.get("risk_tier") is not None,
        "disclosures_accepted": state.get("disclosures_accepted", False),
        "signature_captured": state.get("signature_captured", False)
    }
    
    # DETERMINISTIC PRE-FILTER: Skip LLM when next step is obvious
    if not completed["docs_complete"]:
        return _build_router_result("doc_collection", 1.0, "Documents incomplete", {}, state)
    
    if not completed["kyc_complete"]:
        return _build_router_result("kyc_screening", 1.0, "KYC pending", {}, state)
    
    if not completed["risk_assigned"]:
        return _build_router_result("risk_classification", 1.0, "Risk tier unassigned", {}, state)
    
    if not completed["disclosures_accepted"]:
        return _build_router_result("disclosure_delivery", 1.0, "Disclosures pending", {}, state)
    
    if not completed["signature_captured"]:
        return _build_router_result("e_signature", 1.0, "Signature pending", {}, state)
    
    # All steps complete
    return _build_router_result("complete", 1.0, "Onboarding complete", {}, state)


def _build_router_result(step, confidence, reasoning, extracted, state):
    return {
        "current_step": step,
        "execution_trace": [{
            "node": "router",
            "step": step,
            "confidence": confidence,
            "reasoning": reasoning,
            "timestamp": datetime.utcnow().isoformat()
        }],
        "current_iteration": 0,
        "max_global_iterations": 10,
        "max_doc_attempts": 3,
        "max_recovery_attempts": 2,
        "max_data_age_seconds": 300,
        "error_count": 0,
        "recovery_attempts": 0
    }

Step 3: Bounded Document Collection Node — Loop Prevention

This is where most onboarding systems fail. We enforce hard limits and provide alternative paths.

async def doc_collection_node(state: OnboardingState) -> dict:
    """
    Handles document upload/validation with STRICT loop prevention.
    - Max attempts per document type
    - Alternative verification paths after failures
    - Structured rejection responses (not generic errors)
    """
    attempts = state.get("document_attempts", {})
    max_attempts = state.get("max_doc_attempts", 3)
    iteration = state.get("current_iteration", 0)
    
    # GLOBAL ITERATION CEILING
    if iteration >= state.get("max_global_iterations", 10):
        return {
            "onboarding_status": "pending_review",
            "status_reason": "Maximum processing iterations reached. Manual review required.",
            "execution_trace": [{"node": "doc_collection", "status": "MAX_ITERATIONS", "iteration": iteration}],
            "audit_trail": [{"event": "max_iterations_reached", "step": "doc_collection", "timestamp": datetime.utcnow().isoformat()}]
        }
    
    # Determine which document is needed
    verified = state.get("verified_documents", {})
    needed_docs = []
    for doc_type in ["government_id", "selfie", "proof_of_address"]:
        if doc_type not in verified:
            needed_docs.append(doc_type)
    
    if not needed_docs:
        return {
            "current_step": "kyc_screening",
            "execution_trace": [{"node": "doc_collection", "status": "ALL_DOCS_COMPLETE"}]
        }
    
    current_doc = needed_docs[0]
    current_attempt = attempts.get(current_doc, {}).get("attempt_number", 0)
    
    # PER-DOCUMENT ATTEMPT CEILING
    if current_attempt >= max_attempts:
        # ESCALATE instead of looping
        return {
            "current_step": "escalated",
            "onboarding_status": "pending_review",
            "status_reason": f"{current_doc} failed validation {max_attempts} times. Manual verification required.",
            "document_attempts": {
                current_doc: {
                    "doc_type": current_doc,
                    "attempt_number": current_attempt,
                    "status": "rejected",
                    "rejection_reason": "max_attempts_exceeded",
                    "timestamp": datetime.utcnow()
                }
            },
            "execution_trace": [{"node": "doc_collection", "status": "DOC_MAX_ATTEMPTS", "doc": current_doc}],
            "audit_trail": [{
                "event": "document_max_attempts",
                "doc_type": current_doc,
                "attempts": current_attempt,
                "timestamp": datetime.utcnow().isoformat()
            }]
        }
    
    # Request document from user (structured prompt, not open-ended)
    doc_requirements = {
        "government_id": "Clear photo of front and back of government-issued ID. No glare, all corners visible.",
        "selfie": "Front-facing selfie matching your ID photo. Neutral background, no sunglasses.",
        "proof_of_address": "Utility bill or bank statement dated within 90 days. Full document visible."
    }
    
    return {
        "messages": [{
            "role": "assistant",
            "content": f"Please upload your {current_doc.replace('_', ' ')}. "
                       f"{doc_requirements[current_doc]} "
                       f"(Attempt {current_attempt + 1} of {max_attempts})"
        }],
        "current_iteration": iteration + 1,
        "execution_trace": [{
            "node": "doc_collection",
            "status": "awaiting_upload",
            "doc_type": current_doc,
            "attempt": current_attempt + 1,
            "timestamp": datetime.utcnow().isoformat()
        }]
    }


async def process_document_upload(state: OnboardingState, uploaded_doc: Dict) -> dict:
    """
    Validates uploaded document via OCR API.
    Returns structured acceptance/rejection with specific feedback.
    NEVER returns generic errors that cause retry loops.
    """
    doc_type = uploaded_doc["doc_type"]
    attempts = state.get("document_attempts", {})
    current_attempt = attempts.get(doc_type, {}).get("attempt_number", 0) + 1
    
    # Call OCR/validation API
    validation = await validate_document_api(uploaded_doc)
    
    if validation["status"] == "accepted":
        return {
            "verified_documents": {doc_type: validation["extracted_data"]},
            "document_attempts": {
                doc_type: {
                    "doc_type": doc_type,
                    "attempt_number": current_attempt,
                    "status": "accepted",
                    "rejection_reason": None,
                    "timestamp": datetime.utcnow()
                }
            },
            "data_freshness": {doc_type: datetime.utcnow()},
            "execution_trace": [{"node": "process_upload", "status": "accepted", "doc": doc_type}],
            "audit_trail": [{
                "event": "document_accepted",
                "doc_type": doc_type,
                "attempt": current_attempt,
                "timestamp": datetime.utcnow().isoformat()
            }]
        }
    else:
        # SPECIFIC rejection reason prevents vague retry loops
        return {
            "document_attempts": {
                doc_type: {
                    "doc_type": doc_type,
                    "attempt_number": current_attempt,
                    "status": "rejected",
                    "rejection_reason": validation["rejection_reason"],
                    "timestamp": datetime.utcnow()
                }
            },
            "messages": [{
                "role": "assistant",
                "content": f"Document rejected: {validation['rejection_reason']}. "
                           f"Please correct this specific issue and re-upload."
            }],
            "execution_trace": [{"node": "process_upload", "status": "rejected", "reason": validation["rejection_reason"]}],
            "audit_trail": [{
                "event": "document_rejected",
                "doc_type": doc_type,
                "attempt": current_attempt,
                "reason": validation["rejection_reason"],
                "timestamp": datetime.utcnow().isoformat()
            }]
        }

Step 4: KYC Screening Node — Dead End Prevention

Every external API call has explicit error handling with fallback paths.

async def kyc_screening_node(state: OnboardingState) -> dict:
    """
    Runs sanctions/PEP screening with DEAD END PREVENTION.
    - Timeout handling
    - API failure fallback
    - Guaranteed audit record even on failure
    """
    verified_docs = state.get("verified_documents", {})
    iteration = state.get("current_iteration", 0)
    
    try:
        # Call sanctions screening API with timeout
        screening_result = await screen_applicant_api(
            name=verified_docs["government_id"]["full_name"],
            dob=verified_docs["government_id"]["date_of_birth"],
            country=state["applicant_context"]["jurisdiction"],
            timeout_seconds=10
        )
        
        return {
            "kyc_result": screening_result,
            "data_freshness": {"kyc_screening": datetime.utcnow()},
            "current_step": "risk_classification",
            "current_iteration": iteration + 1,
            "execution_trace": [{"node": "kyc_screening", "status": "success", "match": screening_result.get("match_found")}],
            "audit_trail": [{
                "event": "kyc_screening_complete",
                "match_found": screening_result.get("match_found", False),
                "timestamp": datetime.utcnow().isoformat()
            }]
        }
        
    except TimeoutError:
        # DEAD END PREVENTION: Never hang; escalate with audit record
        return {
            "current_step": "escalated",
            "onboarding_status": "pending_review",
            "status_reason": "KYC screening API timed out. Manual screening required.",
            "error_count": state.get("error_count", 0) + 1,
            "last_error": "kyc_api_timeout",
            "execution_trace": [{"node": "kyc_screening", "status": "timeout"}],
            "audit_trail": [{
                "event": "kyc_screening_timeout",
                "action": "escalated_to_manual",
                "timestamp": datetime.utcnow().isoformat()
            }]
        }
        
    except Exception as e:
        # CATCH-ALL: Every failure path produces an audit record
        error_count = state.get("error_count", 0) + 1
        
        if error_count >= state.get("max_recovery_attempts", 2):
            return {
                "current_step": "escalated",
                "onboarding_status": "pending_review",
                "status_reason": f"KYC screening failed after {error_count} attempts. Manual review required.",
                "error_count": error_count,
                "last_error": str(e),
                "execution_trace": [{"node": "kyc_screening", "status": "failed", "error": str(e)}],
                "audit_trail": [{
                    "event": "kyc_screening_failure",
                    "error": str(e),
                    "attempts": error_count,
                    "action": "escalated",
                    "timestamp": datetime.utcnow().isoformat()
                }]
            }
        
        # Retry once before escalating
        return {
            "error_count": error_count,
            "last_error": str(e),
            "recovery_attempts": state.get("recovery_attempts", 0) + 1,
            "current_iteration": iteration + 1,
            "execution_trace": [{"node": "kyc_screening", "status": "retry", "error": str(e)}],
            "audit_trail": [{
                "event": "kyc_screening_retry",
                "error": str(e),
                "attempt": error_count,
                "timestamp": datetime.utcnow().isoformat()
            }]
        }

Step 5: State Validator — Catch Failures Before Users See Them

Every path terminates at the validator. It ensures completeness, freshness, and detects loops.

async def state_validator_node(state: OnboardingState) -> dict:
    """
    Final gatekeeper. Validates state completeness and detects anti-patterns.
    Routes to recovery or escalation instead of returning broken states.
    """
    step = state.get("current_step")
    iteration = state.get("current_iteration", 0)
    max_iter = state.get("max_global_iterations", 10)
    
    # LOOP DETECTION: Check for repeated node sequences in trace
    trace_nodes = [t["node"] for t in state.get("execution_trace", [])]
    if len(trace_nodes) >= 6:
        for window in range(2, len(trace_nodes) // 2 + 1):
            for i in range(len(trace_nodes) - window * 2):
                seq = tuple(trace_nodes[i:i+window])
                next_seq = tuple(trace_nodes[i+window:i+window*2])
                if seq == next_seq:
                    return {
                        "current_step": "escalated",
                        "onboarding_status": "pending_review",
                        "status_reason": "Processing loop detected. Manual review required.",
                        "execution_trace": [{"node": "validator", "status": "LOOP_DETECTED", "pattern": list(seq)}],
                        "audit_trail": [{"event": "loop_detected", "pattern": list(seq), "timestamp": datetime.utcnow().isoformat()}]
                    }
    
    # GLOBAL ITERATION CEILING
    if iteration >= max_iter:
        return {
            "current_step": "escalated",
            "onboarding_status": "pending_review",
            "status_reason": "Maximum iterations reached. Manual review required.",
            "execution_trace": [{"node": "validator", "status": "MAX_ITERATIONS"}],
            "audit_trail": [{"event": "max_iterations_validator", "iterations": iteration, "timestamp": datetime.utcnow().isoformat()}]
        }
    
    # STALENESS CHECK
    freshness = state.get("data_freshness", {})
    max_age = state.get("max_data_age_seconds", 300)
    stale_sources = [
        src for src, ts in freshness.items()
        if (datetime.utcnow() - ts).total_seconds() > max_age
    ]
    
    if stale_sources and step not in ("escalated", "complete"):
        return {
            "execution_trace": [{"node": "validator", "status": "STALE_DATA", "sources": stale_sources}],
            "audit_trail": [{"event": "stale_data_detected", "sources": stale_sources, "timestamp": datetime.utcnow().isoformat()}]
            # Don't fail; just log. Freshness is informational for onboarding.
        }
    
    # STEP COMPLETENESS CHECK
    if step == "kyc_screening" and not state.get("verified_documents"):
        return {
            "current_step": "doc_collection",
            "execution_trace": [{"node": "validator", "status": "REDIRECT_TO_DOCS"}],
            "audit_trail": [{"event": "redirect_missing_docs", "timestamp": datetime.utcnow().isoformat()}]
        }
    
    # State is valid
    return {
        "execution_trace": [{"node": "validator", "status": "PASSED", "step": step}]
    }


def route_after_validation(state: OnboardingState) -> str:
    """Deterministic routing. No LLM involved."""
    step = state.get("current_step")
    
    if step == "escalated":
        return "human_escalation"
    if step == "complete":
        return "finalize_approved"
    if step == "doc_collection":
        return "doc_collection"
    if step == "kyc_screening":
        return "kyc_screening"
    if step == "risk_classification":
        return "risk_classification"
    if step == "disclosure_delivery":
        return "disclosure_delivery"
    if step == "e_signature":
        return "e_signature"
    
    # Safety fallback: never orphan
    return "human_escalation"

Step 6: Assemble the Constrained Graph

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver

workflow = StateGraph(OnboardingState)

# Add nodes
workflow.add_node("router", router_node)
workflow.add_node("doc_collection", doc_collection_node)
workflow.add_node("kyc_screening", kyc_screening_node)
workflow.add_node("risk_classification", risk_classification_node)  # Similar bounded pattern
workflow.add_node("disclosure_delivery", disclosure_node)
workflow.add_node("e_signature", signature_node)
workflow.add_node("state_validator", state_validator_node)

# Terminal nodes
workflow.add_node("finalize_approved", lambda s: {
    "onboarding_status": "approved",
    "execution_trace": [{"node": "finalize", "status": "approved"}],
    "audit_trail": [{"event": "onboarding_approved", "timestamp": datetime.utcnow().isoformat()}]
})

workflow.add_node("human_escalation", lambda s: {
    "onboarding_status": "pending_review",
    "execution_trace": [{"node": "escalation", "status": "queued"}],
    "audit_trail": [{"event": "human_escalation", "reason": s.get("status_reason"), "timestamp": datetime.utcnow().isoformat()}]
})

# Edges
workflow.add_edge(START, "router")

# Router → deterministic step routing
workflow.add_conditional_edges("router", lambda s: s.get("current_step", "escalated"), {
    "doc_collection": "doc_collection",
    "kyc_screening": "kyc_screening",
    "risk_classification": "risk_classification",
    "disclosure_delivery": "disclosure_delivery",
    "e_signature": "e_signature",
    "complete": "state_validator",
    "escalated": "human_escalation",
    "clarification_needed": "doc_collection"  # Default safe fallback
})

# All operational nodes → validator
workflow.add_edge("doc_collection", "state_validator")
workflow.add_edge("kyc_screening", "state_validator")
workflow.add_edge("risk_classification", "state_validator")
workflow.add_edge("disclosure_delivery", "state_validator")
workflow.add_edge("e_signature", "state_validator")

# Validator → deterministic routing
workflow.add_conditional_edges("state_validator", route_after_validation, {
    "doc_collection": "doc_collection",
    "kyc_screening": "kyc_screening",
    "risk_classification": "risk_classification",
    "disclosure_delivery": "disclosure_delivery",
    "e_signature": "e_signature",
    "finalize_approved": "finalize_approved",
    "human_escalation": "human_escalation"
})

# Terminal edges
workflow.add_edge("finalize_approved", END)
workflow.add_edge("human_escalation", END)

# Compile with PostgreSQL checkpointing for regulatory audit
checkpointer = PostgresSaver.from_conn_string("postgresql://novabank-onboarding-db")
app = workflow.compile(checkpointer=checkpointer)

Step 7: Execute with Full Observability

config = {"configurable": {"thread_id": "nova-app-NB-2024-884721"}}

result = await app.ainvoke({
    "applicant_context": {
        "application_id": "NB-2024-884721",
        "email": "[email protected]",
        "jurisdiction": "US",
        "product_type": "checking_account"
    },
    "messages": [{"role": "user", "content": "I want to open a checking account"}],
    "document_attempts": {},
    "verified_documents": {},
    "kyc_result": None,
    "risk_tier": None,
    "disclosures_accepted": False,
    "signature_captured": False,
    "data_freshness": {},
    "execution_trace": [],
    "audit_trail": [],
    "session_id": "sess-nb-20240805-143022",
    "application_id": "NB-2024-884721",
    "processing_start_time": datetime.utcnow(),
    "onboarding_status": None,
    "status_reason": None,
    "current_step": None,
    "current_iteration": 0,
    "error_count": 0,
    "recovery_attempts": 0
}, config=config)

print(f"Status: {result['onboarding_status']}")
print(f"Current Step: {result['current_step']}")
print(f"Iterations: {result['current_iteration']}")
print(f"Doc Attempts: {result['document_attempts']}")
print(f"Trace: {[t['node'] + ':' + t.get('status','') for t in result['execution_trace']]}")
print(f"Audit Events: {[a['event'] for a in result['audit_trail']]}")

Anti-Pattern Prevention Cheat Sheet

Failure ModePrevention PatternWhere Enforced
Infinite Doc Loopmax_doc_attempts per document + escalation pathdoc_collection_node
Global Loopmax_global_iterations ceiling + sequence detectionValidator + every node
Re-Routing Loopcurrent_step set ONCE at router; never re-evaluatedRouter immutability
Dead End (API Fail)Try/except with escalation + guaranteed audit recordkyc_screening_node
Silent FailureValidator checks completeness; redirects or escalatesstate_validator_node
Unnecessary ChatterDeterministic pre-filter in router; skip LLM when obviousrouter_node
LLM Routing IndecisionAll conditional edges are Python functionsEdge routing functions
Stale Data Usagedata_freshness timestamps + validator staleness checkState + Validator
Repeated QuestionsState-read-before-ask guard in doc collectiondoc_collection_node
Missing Audit RecordEvery code path writes to audit_trailAll nodes

Production Metrics for Digital Banking Onboarding

metrics = {
    "avg_iterations_per_onboarding": 4.2,      # Target: ≤6
    "loop_detection_rate": 0.05,               # Target: <0.1%
    "doc_rejection_rate_attempt_1": 0.18,       # Industry benchmark: 15-25%
    "doc_rejection_rate_attempt_3": 0.03,       # Should drop significantly
    "escalation_rate": 0.08,                   # Target: <10%
    "dead_end_rate": 0.001,                    # Target: <0.1%
    "p95_latency_seconds": 12,                 # Target: <15s
    "audit_completeness": 1.0,                 # MUST be 100%
    "conversion_rate": 0.72                    # Target: >70%
}

Conclusion

Preventing loops, dead ends, and chatter in digital banking onboarding isn't about smarter agents—it's about dumber, more constrained graphs. The patterns demonstrated here share a common philosophy:

  1. Classify once, execute deterministically. Never let agents renegotiate workflow position.

  2. Bound everything. Document attempts, global iterations, recovery retries—all have hard ceilings with escalation paths.

  3. Validate at every exit. Every path ends at a validator that catches failures before users see them.

  4. Route with code, not LLMs. Conditional edges are Python functions, not model calls.

  5. Audit everything, always. Every code path—success, failure, escalation—writes an immutable audit record.

In digital banking, a loop isn't wasted tokens—it's a violated fair lending SLA. A dead end isn't a UX bug—it's a compliance gap. Unnecessary chatter isn't latency—it's conversion revenue lost. The constraints aren't engineering overhead; they are regulatory requirements encoded in graph topology.