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.