09-mini-projects

Mini Project 5: Fraud Investigation Pipeline

An Embabel agent running typed fraud checks that plans a path to auto-clear or human escalation, logging every step for audit.

August 15, 2026
spring-aiembabelgoapaudit-trailhuman-in-the-loopmini-project

"The Model Decided" Is Not an Audit Answer

A fraud team lead asks a simple question about a transaction the system auto-cleared last week: why? A prose explanation from an LLM, generated after the fact by asking it to justify a decision it already made, is not an audit trail — it's a plausible-sounding story that may or may not describe what actually happened. Regulated fraud decisioning needs the opposite: a record of exactly which checks ran, in what order, with what typed results, before any clear-or-escalate decision was reached.

This project extends the fraud-signals narrative from this roadmap's LangChain4j phase into a full Embabel @Agent — several typed checks feeding a planner-computed path to either auto-clear or escalate, with the plan itself, not a post-hoc summary, serving as the audit record.


Setup

groovy
// build.gradle
dependencies {
    implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter'
    implementation 'com.embabel.agent:embabel-agent-starter'
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

This project assumes familiarity with Embabel's @Agent, @Action, and GOAP planning from this roadmap's fifth phase — if the idea of a planner choosing which @Actions to run based on declared preconditions and effects is new, that phase is the prerequisite this project builds directly on.


1. Typed Checks as Embabel Actions

Each fraud signal is its own @Action with a narrow, typed responsibility — not one large method doing everything:

java
public record TransactionHistoryResult(boolean flagged, String reason) {}
public record AnomalyResult(boolean flagged, double anomalyScore) {}
public record VelocityResult(boolean flagged, int transactionsLastHour) {}
 
@Agent(description = "Investigates a transaction for fraud signals and routes to clear or escalate")
public class FraudInvestigationAgent {
 
    @Action
    TransactionHistoryResult checkHistory(Transaction tx, HistoryService history) {
        var priorFlags = history.flaggedTransactionCount(tx.accountId(), Duration.ofDays(90));
        return new TransactionHistoryResult(priorFlags > 2, "priorFlags=" + priorFlags);
    }
 
    @Action
    AnomalyResult checkAnomaly(Transaction tx, AnomalyDetectionService anomaly) {
        double score = anomaly.score(tx);
        return new AnomalyResult(score > 0.8, score);
    }
 
    @Action
    VelocityResult checkVelocity(Transaction tx, VelocityService velocity) {
        int count = velocity.transactionCountLastHour(tx.accountId());
        return new VelocityResult(count > 10, count);
    }
}

Each result is a small, typed record — not a free-text explanation. That typing is what makes the eventual audit log queryable ("show me every transaction where VelocityResult.flagged was true") instead of a pile of prose someone would have to re-read to answer the same question.


2. Letting the Planner Decide the Path

Rather than hardcoding "run all three checks, then decide," declare the decision as a goal and let Embabel's GOAP planner determine which checks are actually needed:

java
public record FraudDecision(Verdict verdict, List<String> triggeredChecks, String auditSummary) {}
 
public enum Verdict { AUTO_CLEAR, ESCALATE }
 
@AchievesGoal(description = "Produce a fraud verdict for the transaction")
@Action
FraudDecision decide(
    Transaction tx,
    TransactionHistoryResult history,
    AnomalyResult anomaly,
    VelocityResult velocity
) {
    List<String> triggered = new ArrayList<>();
    if (history.flagged()) triggered.add("history: " + history.reason());
    if (anomaly.flagged()) triggered.add("anomaly: score=" + anomaly.anomalyScore());
    if (velocity.flagged()) triggered.add("velocity: " + velocity.transactionsLastHour() + " tx/hr");
 
    Verdict verdict = triggered.isEmpty() ? Verdict.AUTO_CLEAR : Verdict.ESCALATE;
    return new FraudDecision(verdict, triggered, String.join("; ", triggered));
}

This example's planner path is intentionally simple (run all three checks, then decide) so the planning concept stays visible. GOAP earns its keep on branchier versions of this same agent — a high-anomaly-score transaction skipping straight to a specialist review action, or a first-time-payee transaction triggering an extra identity-verification check the planner only inserts when its precondition (no prior successful payment to this payee) is met. Author the branch as its own @Action with the right preconditions/effects and the planner routes to it automatically, unlike a hand-written if/else chain that has to be extended by hand for every new branch.


3. Human-in-the-Loop Escalation

An ESCALATE verdict routes to a human reviewer queue, not a rejected transaction — the agent's job is triage, not the final call:

java
@Service
public class FraudDecisionHandler {
 
    private final ReviewQueueRepository reviewQueue;
    private final AuditLogRepository auditLog;
 
    public void handle(Transaction tx, FraudDecision decision) {
        auditLog.save(new AuditEntry(
            tx.id(), decision.verdict(), decision.triggeredChecks(),
            Instant.now()
        ));
 
        if (decision.verdict() == Verdict.ESCALATE) {
            reviewQueue.enqueue(new ReviewItem(tx, decision.auditSummary()));
        } else {
            tx.markCleared();
        }
    }
}

The AuditEntry written here is the actual audit trail — a queryable record of which specific checks fired, not a narrative the model was asked to produce separately. A reviewer opening an escalated item sees exactly why it's in their queue, in the same typed shape every other escalation used.


Putting It Together

java
@Agent(description = "Investigates a transaction for fraud signals and routes to clear or escalate")
public class FraudInvestigationAgent {
 
    @Action
    TransactionHistoryResult checkHistory(Transaction tx, HistoryService history) { /* section 1 */ }
 
    @Action
    AnomalyResult checkAnomaly(Transaction tx, AnomalyDetectionService anomaly) { /* section 1 */ }
 
    @Action
    VelocityResult checkVelocity(Transaction tx, VelocityService velocity) { /* section 1 */ }
 
    @AchievesGoal(description = "Produce a fraud verdict for the transaction")
    @Action
    FraudDecision decide(Transaction tx, TransactionHistoryResult history,
                          AnomalyResult anomaly, VelocityResult velocity) { /* section 2 */ }
}
java
FraudDecision decision = agentPlatform.runAgent(FraudInvestigationAgent.class, tx);
fraudDecisionHandler.handle(tx, decision);

Three narrow, typed checks; a planner-driven path to a verdict instead of a hardcoded chain; a human queue for anything ambiguous, with a real typed audit entry backing every decision — the shape a regulated decisioning system actually needs, not the shape a chatbot demo defaults to.


What's Next

Mini Project 9 (Incident Postmortem Drafting Agent) uses a different Embabel/evaluation combination — a custom LLM-as-judge rubric scoring an agent's draft output before a human sees it — worth pairing with this project's audit-trail discipline if your team is building out several agent-backed decisioning workflows at once.

Frequently asked questions

Why typed records for each check's result instead of letting each @Action return a free-text explanation?

A typed record is queryable and diffable in a way free text isn't — "show every transaction where VelocityResult.flagged was true" is a database query against typed data, not a text-search-and-hope against a pile of generated prose. Reserve free text for the parts genuinely meant for a human to read (the audit summary), not for the underlying signal a downstream system needs to reason about.

Does the LLM ever make the actual clear/escalate call, or only assemble typed inputs for a deterministic decide() method?

In this guide, decide() is deterministic Java logic over typed inputs — no model call in the final verdict step at all. That's a deliberate choice for a regulated decisioning system: the LLM's role here is upstream, in services like AnomalyDetectionService that might use a model to score anomalies, not in the accountability-bearing final verdict.

How is this different from a plain if/else chain calling the same three check methods in a fixed order?

For exactly this three-check example, not very different in outcome — the planner's advantage shows up once branches multiply (see the callout in section 2): a specialist review path, an identity-verification step, a different check set for a different transaction type. GOAP scales by adding actions with the right preconditions; a hand-written chain scales by growing an increasingly tangled set of nested conditionals.

What happens to a transaction that's auto-cleared but turns out later to have been fraudulent?

That's a model/threshold quality problem, not something this pipeline's architecture solves on its own — track false-negative rate against confirmed fraud reports the same way any classifier is monitored, and feed that back into tuning the anomaly threshold or adding a new check, rather than treating the pipeline as fire-and-forget once shipped.