cd /news/developer-tools/no-the-llm-doesn-t-get-to-approve-yo… · home topics developer-tools article
[ARTICLE · art-114071] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer building an LLM-powered support agent documented why refund eligibility is handled by deterministic Java rules rather than a model judgment. The decision, recorded in an ADR, uses plain records and three rules to enforce policy, with unit tests pinning down edge cases. The approach avoids the context, hallucination, and cost risks of letting an LLM decide refunds.

read4 min views1 publishedAug 28, 2026

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 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 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.

── more in #developer-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/no-the-llm-doesn-t-g…] indexed:0 read:4min 2026-08-28 ·