# No, the LLM Doesn't Get to Approve Your Refund

> Source: <https://dev.to/tonal/no-the-llm-doesnt-get-to-approve-your-refund-59il>
> Published: 2026-08-28 10:21:16+00:00

*ADR 001: why refund eligibility is deterministic Java, not a model judgment*

Part 3 of an ongoing experiment: building an LLM-powered support agent with deterministic boundaries. The

[companion repo]grows with the series.

[Post 2](https://dev.to/tonal/drawing-the-line-what-deserves-an-llm-and-what-doesnt-11lj) gave us the ruler: facts with high cost and assertable answers belong to software. This post applies it to the most consequential component in the system — refund eligibility — and documents the decision as we've actually recorded it, in ADR form.

The LLM-decided version writes itself:

```
// The version we did NOT build
public EvaluationResult evaluate(Order order, RefundRequest request) {
    String verdict = llm.call("""
        You are a refund approver. Given this order and request,
        decide if a refund is appropriate. Order: %s Request: %s
        Answer with JSON {"eligible": bool, "reason": string}.
        """.formatted(order, request));
    return parse(verdict);
}
```

It's ten lines. It handles edge cases nobody thought of ("the customer paid twice by mistake"). It sounds *smart* in a design review. And it fails in four ways we can't fix with a better prompt:

**Context.** The agent must determine whether a refund can proceed. Options: (a) LLM decides at runtime, (b) hybrid — LLM pre-screens, rules decide, (c) deterministic rules decide, period.

**Decision.** Option (c). Refund eligibility encodes published policy: delivery status, payment status, return window. These are yes/no facts about stored data. They live in the `domain`

package as plain Java, tested with JUnit, compiled with zero AI dependencies.

**Consequences we accepted:**

```
flowchart TB
    subgraph T["The path we did not build"]
        direction LR
        T1["Order + policy as prompt"] --> T2["LLM verdict"] --> T3["Refund executes"]
    end
    subgraph W["What we built"]
        direction LR
        W1["Stored facts"] --> W2["Three rules"] --> W3["Verdict + reason"] --> W4["Risk-tier gate"] --> W5["Human approves"]
    end
    T ~~~ W
    style T fill:#f5ecec,stroke:#c4a29e,color:#5a4442
    style W fill:#ecf2ed,stroke:#93b39d,color:#3d5344
    classDef step fill:#eef2f6,stroke:#8fa3b8,color:#24313f
    class T1,T2,T3,W1,W2,W3,W4,W5 step
```

The data lives in three plain records — an `Order`

(delivered, paid, order date), a `RefundRequest`

, and an `EvaluationResult`

that pairs a verdict with a human-readable reason. Nothing surprising; the full definitions are in the companion repo.

The rules themselves are the centerpiece:

```
// dev/tonal/support/domain/RefundEligibility.java
public final class RefundEligibility {

    static final int RETURN_WINDOW_DAYS = 30;

    private final OrderRepository orderRepo;

    public RefundEligibility(OrderRepository orderRepo) {
        this.orderRepo = orderRepo;
    }

    public EvaluationResult evaluate(Order order, RefundRequest request) {
        if (!order.delivered()) {
            return EvaluationResult.notEligible("Order must be delivered before refund");
        }
        if (!order.paid()) {
            return EvaluationResult.notEligible("Order must be paid before refund");
        }
        if (order.getAgeInDays() > RETURN_WINDOW_DAYS) {
            return EvaluationResult.notEligible(
                    "Outside return window of " + RETURN_WINDOW_DAYS + " days");
        }
        return EvaluationResult.eligible(order.id(), order.customerId());
    }
}
```

What each choice buys:

`eligible(...)`

is a fact about policy, not an instruction to move money.Five unit tests pin the whole thing down — undelivered orders, unpaid orders, past-window orders, the window-boundary day (because "30 days" must be inclusive in exactly one direction, and only a test makes that stick). One of them:

```
// dev/tonal/support/domain/RefundEligibilityTest.java
@Test
void shouldNotRefundUndeliveredOrder() {
    Order undelivered = orderRepo.save(new Order(
            "ORD-1", "C001", false, true, LocalDate.now().minusDays(5)));

    EvaluationResult result =
            eligibility.evaluate(undelivered, new RefundRequest("ORD-1", "never arrived"));

    assertThat(result.eligible()).isFalse();
    assertThat(result.reason()).contains("Order must be delivered before refund");
}
```

The rejection-reason assertion checks the *exact string* — the reason is part of the contract with the customer-facing agent. No mocking framework appears anywhere: the domain logic runs against a trivial in-memory repository, which is itself the design signal. All green with no API key configured.

None of this demotes the model. Intent interpretation stays exactly where [Post 1](https://dev.to/tonal/an-agent-on-a-leash-or-why-my-ai-agent-doesnt-make-business-decisions-1o1) put it: turning "my order never showed up, I want my money back" into a typed request the rules can consume — a judgment call, low direct cost, evaluated statistically rather than asserted. Each component does what it's actually good at, behind a contract the other can rely on.

Deterministic rules are exactly as good as the policy they encode, and real customers generate cases that don't fit: double payments, good-faith late returns, shipping failures on our end. The system's answer isn't to smuggle judgment into eligibility — those cases have a designated exit (human review, policy exceptions). The boundary doesn't pretend nuance doesn't exist; it refuses to let nuance impersonate arithmetic.

Scoring models propose, deterministic logic disposes — how loan underwriting already works, why clinical decision support keeps prescription rights away from recommenders, why industrial interlocks treat perception as input and law as law.
