Research date: September 27, 2026. An architecture guide with an executable Java 21 example.

Typed AI decisions flow from a model signal through application policy to routing or review.

A customer writes: “My payment failed, and now I cannot log in.” Should that ticket go to billing or technical support? A language model can suggest a queue, but your application still has to decide whether to trust that suggestion—and what to do when the answer is unclear.

Typed decision interfaces give us a useful way to handle this. The model returns a label or score, and application code decides what happens next. In Java, that means we can give uncertain answers, unsupported requests and failed calls a clear place in the design.

Give your routing logic a way to say “this needs a human,” and test that path as carefully as a successful automatic route.

Why this topic matters now

On September 21, Spring introduced its community integration with TypeSafe's hosted Jev service. The integration supports classification and scoring rather than prose generation. The announcement identifies routing, evaluation and retrieval processing as use cases. It is a practical example of using AI for decisions that need no written answer. The announcement introduced version 0.1.0 of a separate community project, so you will need to add the integration to use it. Spring announcement

Two other developments deserve attention. Spring AI 2.1.0-M1 was announced on September 25; it is a milestone, and its announcement describes further agent support as planned work. On September 21, Spring also announced a consolidated release train, with the next regular patch train scheduled for October 22. There is plenty to explore, alongside changes to how teams plan their Spring updates. Spring AI milestone · Spring release cadence

If you build with Java and Spring, typed decisions are worth a closer look. They let you keep business rules in familiar, testable code while choosing the model that best fits the task. Let's work through what that looks like for support-ticket routing.

From a typed answer to a useful decision

Spring AI's output converters map generated responses to structured types. Their documentation describes conversion as best effort and recommends schema validation. A response that parses correctly still needs to make sense for the task at hand. Spring AI output converters

Jev exposes three primitives: Noul for a graded yes/no answer, Choice for a label with a distribution and confidence, and Score for a continuous position on a described scale. Noul has no separate confidence field. Avoid flattening these distinct meanings into one generic “AI score.” Primitive definitions

For our support tickets, start with two questions:

  1. Does this ticket belong to any of the queues supported by this automation?
  2. If so, which queue should receive it?

An identity theft report might mention payments but need a specialist team outside the available options. Even if billing is the highest-scoring label, sending the ticket there could be the wrong move. The community tool-index documentation uses a separate applicability question for a similar reason: selecting from candidates needs a way to represent that none applies. Jev tool search

Three separate checks: supported task, sufficiently clear classification, and permission to route.

Let the application define the available queues. A model may return billing; it should never invent the destination URL, database query or method name that your application executes. Map an accepted identifier to a predefined command handler. Authenticate the caller and resolve the tenant independently of the submitted text.

What does a confidence of 0.9 actually tell you?

The integration's confidence documentation describes confidence as concentration of the answer's probability distribution. It also distinguishes confidence in one answer from stability across repeated calls. Those distinctions matter: a value of 0.9 is not, by itself, evidence of 90% accuracy on your company's tickets. Confidence semantics

Start with historical tickets whose correct destinations have been reviewed. Use one set to tune the policy and keep a separate set for the final evaluation. Include ambiguous cases, unsupported requests, short messages, mixed languages and misleading quoted text. Ask people who know the support process to agree on the right queue and flag cases that need human review. Keep related tickets together when splitting data so that near-duplicates do not inflate the result.

For each candidate threshold, measure both coverage and error among the accepted cases. Reporting only overall accuracy hides whether a system improves by sending almost everything to review. Reporting only coverage hides incorrect automated routing.

Evaluation scorecard showing automation coverage, error among automatic routes, review load and end-to-end latency.

Measure Definition Decision it informs
Automation coverage Automatically routed tickets / all evaluated tickets Whether automation handles a useful share
Accepted error rate Wrong automatic routes / all automatic routes Whether accepted cases are reliable enough
Review rate Tickets sent to review / all evaluated tickets Staffing and queue capacity
End-to-end latency Time from accepted request to recorded outcome User-facing service objectives

If no tickets are routed, accepted error rate is undefined, not zero. Break results down by department and input language; aggregate improvements can conceal a regression for one group. Record the model version, questions, queue descriptions and policy version with each evaluation run so you can reproduce the result.

No provider latency, price or accuracy figures are reproduced as our own measurements here. A production comparison should include networking, retries, review cost and the rest of the workflow, using the same workload for every candidate.

Put the routing rules into Java

The following policy keeps the rules simple. The queue must be known, both signals must be finite numbers between 0 and 1, and both must meet their thresholds. Callers without permission are rejected. Missing, invalid or uncertain answers go to review. Only ROUTE includes a destination.

The thresholds 0.8 and 0.9 are illustrative configuration, not calibrated recommendations. The Judgment record is our application type, not a Jev SDK response. A provider adapter would map an applicability answer and a classification answer into it.

import java.util.Set;

/** Pure application policy. Scores are signals, not authorization. */
public final class DecisionPolicy {
    public enum Outcome { ROUTE, REVIEW, REJECT }
    public record Judgment(String label, double applicability, double confidence) {}
    public record Result(Outcome outcome, String destination, String reason) {}
    private final Set<String> allowed;
    private final double applicabilityFloor;
    private final double confidenceFloor;

    public DecisionPolicy(Set<String> allowed, double applicabilityFloor,
                          double confidenceFloor) {
        if (allowed == null || allowed.isEmpty()
                || allowed.stream().anyMatch(s -> s == null || s.isBlank())
                || !unit(applicabilityFloor) || !unit(confidenceFloor)) {
            throw new IllegalArgumentException("Invalid policy configuration");
        }
        this.allowed = Set.copyOf(allowed);
        this.applicabilityFloor = applicabilityFloor;
        this.confidenceFloor = confidenceFloor;
    }

    public Result decide(Judgment judgment, boolean callerMayRoute) {
        if (!callerMayRoute) return result(Outcome.REJECT, "unauthorized");
        if (judgment == null) return result(Outcome.REVIEW, "unavailable");
        if (!unit(judgment.applicability()) || !unit(judgment.confidence()))
            return result(Outcome.REVIEW, "invalid_signal");
        if (judgment.label() == null || !allowed.contains(judgment.label()))
            return result(Outcome.REVIEW, "unknown_label");
        if (judgment.applicability() < applicabilityFloor)
            return result(Outcome.REVIEW, "not_applicable");
        if (judgment.confidence() < confidenceFloor)
            return result(Outcome.REVIEW, "uncertain");
        return new Result(Outcome.ROUTE, judgment.label(), "accepted");
    }

    private static Result result(Outcome outcome, String reason) {
        return new Result(outcome, null, reason);
    }
    private static boolean unit(double value) {
        return Double.isFinite(value) && value >= 0 && value <= 1;
    }
}

Notice the explicit finite-number check. In Java, comparisons with NaN do not behave like comparisons with ordinary numbers. A policy that merely asks whether a signal is below its floor can accidentally let invalid numeric input through. The boundary rejects invalid signals before threshold decisions.

The policy also takes a copy of the allowed labels. Adding a queue to the original collection later will not quietly make it available to this policy. To change the routing rules, create a new policy version and record the update.

callerMayRoute is a stand-in for a trusted authorization result computed on the server. Never populate it from model output or a request body's boolean. This method does not implement authentication, tenant isolation, persistence or the command itself.

What we tested

The included test enumerates 1,690 combinations of labels, numeric signals and authorization states. For each combination, it checks the routing result, verifies that callers without permission are rejected, and checks whether the result includes a destination. It then checks missing results and invalid configuration. The complete run performs 5,077 assertions.

Cases cover unknown and null labels, NaN, positive and negative infinity, out-of-range values, and representable floating-point values immediately below and above the thresholds. The configuration-copy behavior is exercised by adding an unauthorized label to the original set after constructing the policy.

Run from the package's examples directory with JDK 21:

javac --release 21 -Xlint:all -d out DecisionPolicy.java DecisionPolicyTest.java
java -cp out DecisionPolicyTest

Expected output:

5077 checks passed

These are deterministic policy checks. They establish neither model quality nor production readiness. No live Jev call, Spring integration, JSON deserialization, database or network failure was exercised. The complete test source is included below so the result is reproducible without credentials or paid services.

Connect the policy to the workflow

In a Spring application, use an adapter to call the chosen provider and translate its response. Keep the application policy independent of that adapter. Pin the tested SDK version and verify its actual exception, timeout and response behavior; current documentation can move faster than the version deployed in your service.

The application service should authorize the request before spending money on inference. Apply a request deadline and a bounded retry policy to the provider call. If the call still times out after the allowed retries, send the ticket to review and count the timeout separately in your monitoring. Do not catch every exception and silently call it uncertainty: programming faults and configuration errors need visible failure handling.

Sequence from authorization through inference and policy to a transactional route or a durable review queue.

A REVIEW result needs somewhere to go. Save it in a review queue, assign an owner, record why automatic routing stopped and decide what the customer sees while waiting. Logging “needs review” will not get the ticket to someone who can help. If the review queue is full, apply backpressure or return an explicit pending/unavailable response; do not convert review into automatic acceptance to keep throughput high.

For accepted routes, commit the state change with an idempotency key and an appropriate concurrency check. Recheck mutable authorization and ticket state when executing the command. A correct decision about an old ticket snapshot can become stale before the transaction commits. The sample does not solve that concurrency problem, and its result must not be treated as a durable permission token.

Keep model-derived labels away from higher-impact actions. Routing a support ticket and transferring money require different business controls. Raising a confidence floor does not substitute for transaction limits, explicit approval or independently verified account ownership.

Start small and watch the review queue

Start in shadow mode: record proposed routes without changing real assignments. Compare them with reviewed outcomes, accounting for the possibility that historical human routing also contains errors. Then enable automatic routing for a small, reversible subset with a staffed review queue and a kill switch.

Treat edits to questions and queue descriptions as changes to application behavior, and version them accordingly. Re-run the held-out evaluation before promoting a new model, SDK or policy. Watch for shifts in accepted error, review age and provider failures, rather than simply tracking successful HTTP responses. Minimize stored customer text; use protected references and retention limits where detailed case review is necessary.

This gives you a decision process you can inspect and improve. You can change the model, adjust a threshold or add a queue while keeping the routing rules visible in code. And when the system is unsure, the ticket has a clear path to someone who can resolve it.

Moving an AI prototype into everyday use? At Homann Software, we help teams turn promising ideas into Java and Spring applications they can test, maintain and trust.

Reproducible test source

import java.util.HashSet;
import java.util.Set;

public final class DecisionPolicyTest {
    private static int checks;
    private static void check(boolean condition) {
        checks++;
        if (!condition) throw new AssertionError("Check " + checks);
    }
    private static void invalid(Runnable action) {
        try { action.run(); } catch (IllegalArgumentException expected) {
            check(true); return;
        }
        throw new AssertionError("Configuration should fail");
    }
    public static void main(String[] args) {
        var labels = new HashSet<>(Set.of("billing", "technical"));
        var policy = new DecisionPolicy(labels, 0.8, 0.9);
        labels.add("admin");
        double[] values = {Double.NaN, Double.NEGATIVE_INFINITY,
                Double.POSITIVE_INFINITY, -0.01, 0, Math.nextDown(0.8),
                0.8, Math.nextUp(0.8), Math.nextDown(0.9), 0.9,
                Math.nextUp(0.9), 1, 1.01};
        // Enumerate invalid values and both sides of exact thresholds.
        for (String label : new String[]{"billing", "technical", "admin", "", null})
            for (double applicability : values)
                for (double confidence : values)
                    for (boolean authorized : new boolean[]{true, false}) {
                        var result = policy.decide(new DecisionPolicy.Judgment(
                                label, applicability, confidence), authorized);
                        boolean expectedRoute = authorized
                                && ("billing".equals(label) || "technical".equals(label))
                                && applicability >= 0.8 && applicability <= 1
                                && confidence >= 0.9 && confidence <= 1;
                        check((result.outcome() == DecisionPolicy.Outcome.ROUTE) == expectedRoute);
                        check((result.outcome() == DecisionPolicy.Outcome.REJECT) == !authorized);
                        check(expectedRoute ? label.equals(result.destination()) : result.destination() == null);
                    }
        check(policy.decide(null, true).reason().equals("unavailable"));
        check(policy.decide(null, false).outcome() == DecisionPolicy.Outcome.REJECT);
        invalid(() -> new DecisionPolicy(Set.of(), 0.8, 0.9));
        invalid(() -> new DecisionPolicy(null, 0.8, 0.9));
        invalid(() -> new DecisionPolicy(Set.of(" "), 0.8, 0.9));
        invalid(() -> new DecisionPolicy(Set.of("a"), Double.NaN, 0.9));
        invalid(() -> new DecisionPolicy(Set.of("a"), 0.8, 1.1));
        System.out.println(checks + " checks passed");
    }
}

Sources and scope

Primary sources were accessed on September 27, 2026. The dated Spring announcement is used for release context; the community's latest pages are moving documentation. All diagrams are original explanatory artwork. The policy, workflow recommendations and evaluation scorecard are our engineering analysis, not framework guarantees.