A reusable Spring Boot 4.1 service governs AI execution with Spring Security, LangChain4j, PostgreSQL, Redis, and OpenTelemetry. The provider stays behind a port.
If I were a Java Architect moving into AI Architecture, I would not spend the next month on another chatbot. I would not spend it on a generic retrieval demo either. I would build one reusable service that governs AI execution: who may ask, what it may cost, and which tool call is allowed to become a write. That service is an execution budget and authority gateway. The model proposes. Spring executes. The provider sits behind a port, so swapping it does not move the budget check or the authorization check. The lab is ai-execution-gateway . Run it before you finish the article. Start the stack with ./scripts/up.sh , import postman/ai-execution-gateway.postman_collection.json , and run the four folders in order. The demo keys are already in the collection. Folder 1 finishes PASSED . The candidate stays in SCREENING until you approve. Folder 2 denies the hire. Folder 3 stops on the sixth tool call. Folder 4 should fail on purpose: no key is 401 , another tenant is 404 , a service account is 403 , and a second approval is 409 . Eighteen assertions, and you can watch each one. The numbers below match the stub profile. A live model will choose different calls, and the reconstruction totals will move. The experiment I would run A chatbot demo proves that a model can talk. A RAG demo proves that a model can quote a document. Neither one proves that a run can be stopped, billed, denied, or explained after the fact. Those are the properties I already expect from a Spring service. An agent that skips them is a new process with none of the controls I would put on a payment call. The gateway is the control. One request walks this path: Spring Security turns the API key into a principal, a tenant, and a set of scopes. The service checks that this principal delegated work to this agent. It persists an execution, returns 202 , and runs the loop on a bounded pool. Before every model call and every tool call it reserves budget in Redis. The model returns proposals. The gateway authorizes each one. Only a permitted call reaches a tool. PostgreSQL keeps the steps as hashes. OpenTelemetry keeps the spans. A verdict says whether the run was any good. The model proposes. The gateway decides. A person is still required for the write that matters. I proved it with a synthetic hiring flow, because a stage change is a write I already understand. The same service would govern a refund, a provisioning call, or a document update. The domain is the fixture. The gateway is the product of the experiment. The stack, and the port Java 26. Spring Boot 4.1.0. Spring Security. LangChain4j 1.22 on the live profile. PostgreSQL. Redis. OpenTelemetry. The samples below are the methods in ai-execution-gateway . The governance code does not import a provider SDK. The live adapter is the only class that does. Piece Job in the gateway Spring Security API key to principal. Tenant and scopes come from the principal, never from the body. LangChain4j 1.22 chatModel.chat returns the proposal. The adapter does not run tools, and it does not use AiServices . PostgreSQL Principals, grants, executions, steps, and the domain rows. Flyway owns the schema. Redis Budget counters only. One execution, four numbers, a 24 hour TTL. OpenTelemetry Spans for the execution, the model call, and the tool call. Attributes, not prompts. The live profile builds an OpenAiChatModel against OpenRouter. Request and response logging stay off. Nothing in this builder registers a tool executor. LangChain4jModelGateway.openRouter return OpenAiChatModel.builder() .apiKey(apiKey) .baseUrl(CHAT_BASE_URL) .modelName(modelId) .temperature(0.0) .timeout(Duration.ofSeconds(60)) .logRequests(false) .logResponses(false) .build(); CHAT_BASE_URL is https://openrouter.ai/api/v1 . A blank OPENROUTER_API_KEY fails startup on the live profile. The stub profile never constructs this client. LangChain4jModelGateway.complete ChatResponse response = chatModel.chat(ChatRequest.builder() .messages(toChatMessages(messages)) .toolSpecifications(ToolCatalog.specifications()) .maxOutputTokens(maxOutputTokens) .build()); return new ModelTurn( proposedCalls(response.aiMessage()), inputTokens(response.tokenUsage(), messages), outputTokens(response.tokenUsage())); proposedCalls reads AiMessage.toolExecutionRequests() . An empty list means the model stopped asking. The method returns the proposals. The orchestrator reserves a tool call, authorizes it, and runs a GovernedTool only when the reason is PERMITTED . The port. The provider implements this. The loop does not know which one. public interface ModelGateway { String modelId(); int estimatedInputTokens(List<ConversationMessage> messages); int maxOutputTokens(); ModelTurn complete(String candidateId, List<ConversationMessage> messages); } complete returns proposals and token usage. It must not run a tool. StubModelGateway implements it with a fixed script, so the tests can assert exact totals. LangChain4jModelGateway implements it with chatModel.chat . Another provider is a new class behind this interface. The budget guard, the authorizer, and the audit do not change. A denial does not abort the turn. The model receives a small JSON object, denied set to true and a reason code, and it may continue. I want the next proposal on the record. The run ends early in three cases: Ten turns: FAILED with MAX_TURNS . A surprise exception: FAILED with UNEXPECTED . A full worker queue: the accept call returns 503 , and the row is FAILED with DISPATCH_REJECTED . The loop never starts. The status said completed. The candidate did not move. The domain is synthetic recruiting. Flyway loads a tenant, three principals, one agent, four candidates, and a handful of evidence rows. Nothing in the process talks to a real applicant tracking system. I used hiring stages because that is the workflow I already think about as founder of ITJobOpportunities: a stage, a grant, and a human who approves. This gateway is not that product, and it does not call it. The stages are small on purpose. There is no shortcut from screening to hired. A model that asks for one is asking for an edge the state machine does not have. APPLIED can go to SCREENING or REJECTED . SCREENING can go to INTERVIEW or REJECTED . INTERVIEW can go to OFFER or REJECTED . OFFER can go to HIRED or REJECTED . Five tools exist. The last one is there so a jailbreak has a real method to aim at. The agent's policy does not list it, and no principal has the scope transition:execute . If the dangerous tool is missing, a denial is just a missing name. get_candidate_summary and search_evidence read. draft_recommendation writes a draft. request_status_transition creates a pending request. execute_status_transition would move the candidate. Nobody may call it. COMPLETED means the loop ended. It does not mean the task was good. Read evaluation.successful for that. A pending request is the agent's ceiling. POST /api/v1/transition-requests/{id}/approve is the ceiling of a user. The caller must be a USER in the same tenant, with the scope transition:approve . A service account gets 403 . A user from another tenant gets 404 , the same answer as a missing id, so the existence of the row is not a signal. Approving a request that is no longer pending gets 409 . The packages follow the request, because I got lost when they did not. shared is the edge. gateway is the experiment. recruiting is only the fixture. Package Owns shared HTTP, the API key, the principal, configuration, the worker pool gateway Delegation, the loop, budget, the model port, authorization, the tool contract, audit, the verdict recruiting Candidates, evidence, drafts, transition requests, and the five tool classes The gateway does not import recruiting types except through the tool beans. Recruiting does not import the chat pipeline. A new domain wou...