ADR-0011: Vendor providers as build-time Spring Boot starters behind a small core SPI¶
| Status | Proposed. Phases 0–2 done, and boundary rules (a), (b) and (c) fully enforced as of 2026-10-10. On 2026-10-16 the vendor agent tools moved out of core/agents into their vendor modules (the phase 4 tool-callback step), leaving 0 kernel to vendor Maven edges. The same day the agentspi ports became their own module (core/agentspi), and four of the five vendor modules (google, payments, imagegen, elevenlabs) now depend on it instead of agents; only sarvam still needs agents (one class). Phase 3 and the rest of phases 4–7 not started. See sections 12 to 15. |
| Date | 2026-09-29 |
| Deciders | Platform Engineering (maintainers) |
| Related | ADR-0001 Modular monolith, ADR-0003 Spring AI dynamic clients, ADR-0009 Untrusted code sandboxing, docs/architecture/GENERIC-PROVIDER-CAPABILITY-REGISTRY-2026-08-09.md, docs/architecture/MULTI-PROVIDER-MODALITY-ARCHITECTURE.md |
Numbering: ADR-0001 to ADR-0010 are published as HTML under
docs/platform-documentation/decisions/. This is the next free number. It is Markdown until it is accepted and moved into the HTML docs set.
1. Question¶
A maintainer asked: "Can we separate core modules by provider, so that dependent services are built separately and injected into core as JARs?" In practice that means LLM providers, speech and voice vendors, channels, payments, storage and embeddings become separate artifacts. Core would pick them up either at build time or from a drop-in directory at runtime.
Short answer: yes, at build time, in stages. Runtime drop-in JARs are possible later for third-party providers only. The blocker is not Maven. It is a set of closed Java enums and a few inverted dependencies in the kernel, and those have to be opened up first. Section 3 gives the measurements.
2. Context: what the code looks like today¶
All numbers come from the core/*/pom.xml files and core/*/src/main as of
2026-09-29. They were gathered by parsing the files, without running Maven.
2.1 Module graph, with vendor adapters marked¶
The reactor has 41 Maven modules. mcp-data and mcp-stdio-proxy are not
Maven modules. All of them share the group com.agenticai and the Java
package root com.agenticai.core. The graph below lists compile-scope edges
between sibling modules. Vendor modules are in bold.
| Module | Main src files | Depends on (sibling modules) |
|---|---|---|
| security, audit, crypto, speech, eval, app-config | 27 / 24 / 5 / 3 / 7 / 4 | (none) |
| storage | 6 | security |
| billing (contains Razorpay and PhonePe gateways) | 61 | security |
| secrets | 13 | crypto, audit |
| admin | 33 | audit, security, crypto |
| tenancy | 169 | admin, billing, security, storage |
| elevenlabs | 29 | crypto, security, tenancy, storage, speech |
| sarvam | 27 | crypto, security, tenancy, storage, speech |
| google (Workspace OAuth) | 18 | crypto, security, tenancy |
| knowledge (contains the embeddings, OCR and sidecar clients) | 92 | tenancy, crypto, storage |
| providers (the kernel registry) | 122 | crypto, sarvam, knowledge, tenancy, elevenlabs |
chat (contains 16 LLM *ChatClientFactory classes) |
94 | providers, security, audit, tenancy |
| imagegen | 31 | providers, storage, tenancy |
| payments (UPI mandates) | 30 | security, crypto, billing, approval, eventbus, tenancy, audit |
| agents | 619 | 24 modules, including google, sarvam, imagegen, elevenlabs, payments |
| voice | 31 | security, tenancy, agents, sarvam, elevenlabs, speech, storage, providers, crypto |
| omnichannel (9 channels: WhatsApp, Messenger, Instagram, Slack, Discord, X, Telegram, Email, Teams, plus Twilio) | 228 | agents, approval, chat, audit, guardrails, crypto, eventbus, security, tenancy, devices, speech |
| workflow | 117 | 15 modules, including voice, google, omnichannel |
| app / app-worker / app-jobs | 102 / 1 / 1 | app has 35 siblings. app-worker and app-jobs already leave out payments and brief. |
Two observations matter most.
- The dependency arrow points the wrong way in one kernel module.
providersis the generic registry, and it depends on the vendorssarvamandelevenlabs. The reason is that three vendor adapters live inside the kernel: ElevenLabsTextToSpeechProviderSarvamTextToSpeechProviderSarvamSpeechToTextProvider
All three are in providers/.../modality/. TextToSpeechProviderConfiguration
wires them in by constructor, by name:
factory.register(elevenLabsProvider); factory.register(sarvamProvider);.
ProviderCredentialResolver also has a hard if (providerName == ProviderName.SARVAM)
branch that goes to SarvamCredentialService.
2. Composition by POM already works. app-worker and app-jobs are
separate deployables that include a different set of modules (no payments,
no brief). That is exactly the "distribution" idea proposed here. It is
just not formalised yet.
2.2 Seams that already exist, and how open each one is¶
| Capability | SPI today | How implementations are found | Closed set? | Verdict |
|---|---|---|---|---|
| LLM chat | chat.AgentChatClientFactory (supportedProvider(): ProviderName, create(...)) |
Spring List<AgentChatClientFactory> goes into a ChatClientRegistry Map<ProviderName, ...> |
Yes. enum ProviderName has 15 values. There are 209 ProviderName.X references across 78 main files (agents 15, chat 27, providers 20, imagegen 6, platform 4, other modules 6). enum TextModelProvider has 11 values plus a mapping switch in agents/ChatClientResolver (12 arms). LiveProviderModelFetcher has a per-vendor switch. |
Good bean pattern, but the key is closed |
| LLM validation | providers.validation.ProviderValidator, with 13 validators |
Spring list | Yes (ProviderName) |
Same as above |
| TTS / STT / video-to-text | providers.modality.TextToSpeechProvider / SpeechToTextProvider / VideoToTextProvider |
Manual register() calls from a @Configuration that names each adapter class |
Yes. TtsModelProvider has 2 values, VoiceModelProvider 3, VideoModelProvider 5 |
Adapters sit in the kernel. Needs inversion. |
| Generic capability credentials | providers.capability.ProviderCredentialValidator (providerKey(): String, capabilities(): List<String>) |
Spring list | No. String keys. One implementation so far (agents/decision/JevCredentialValidator). |
This is the right model. Generalise it. |
| File storage | storage.FileStorageService |
@ConditionalOnProperty(app.storage.type = s3 (default) \| local) |
No | Already the target pattern |
| Embeddings | knowledge.embedding.EmbeddingClient |
@ConditionalOnProperty(app.embeddings.provider = openai-compatible), with sidecar as the fallback |
No | Nearly the target pattern |
| Billing gateway | billing.gateway.PaymentGatewayClient (gatewayName(): String) |
Two @Components (Razorpay, PhonePe), both always loaded |
No | Easy to extract |
| Channel outbound media | omnichannel.media.ChannelMediaSender, with 6 implementations |
Spring list | Yes. enum ChannelType has 9 values, plus about 15 case CHANNEL -> arms inside omnichannel. |
Split per channel only after the enum is opened |
| Agent tools that call a vendor | agents/tools/{audio,sarvam,google,imagegen,payments,musicgen,videogen} |
Component scan | n/a | The vendor tool callbacks live in agents (34 files import vendor packages). They must move out with their vendor. |
2.3 Where else the vendor code reaches¶
- Who imports each vendor from outside its own module (main sources only):
elevenlabsis imported by agents (10 files), voice (1), providers (1) and app (1).sarvamis imported by agents (13), providers (5), voice (1) and app (1).googleis imported by agents (8), workflow (1) and app (1).imagegenis imported by agents (8) and app (1).paymentsis imported by agents (5).
Most kernel callers use concrete service classes, not interfaces. For
elevenlabs those are ElevenLabsClient (12 imports),
ElevenLabsGeneratedAudioService (9), ElevenLabsVoiceService (6) and
ElevenLabsApiException (6).
- Database. All 290 Flyway scripts live in core/app/src/main/resources/db/migration/{controlplane (100), tenant (191)}.
Version numbers run up to V365, in one namespace shared by both directories.
Tables that belong to a single vendor include:
- Sarvam: V60, V61, V62, V63, V65
- ElevenLabs: V89
- Google: V28, V215
- Channels: Twilio V4 and V29, Meta V25 and V30, Slack V31, Discord V32, X V33, Telegram V46, Email V47, Teams V240
- Payments: V345, V346
Vendor modules also own JPA entities: elevenlabs 4, sarvam 3, google 1, imagegen 2, payments 4, omnichannel 22.
Every enum above is stored with @Enumerated(EnumType.STRING). No database
CHECK constraint lists provider or channel names; the only CHECK found is
V253__tenant_sso_scim. So switching a column from enum to string is a
change in Java only, with no schema change.
- Spring wiring. CoreApplication, WorkerApplication and JobsApplication
all sit in com.agenticai.core and use @SpringBootApplication (or an
explicit @ComponentScan("com.agenticai.core")). That means every module is
component-scanned, and no module declares AutoConfiguration.imports.
There are 42 @ConditionalOn* annotations, spread thinly across modules.
JPA entities and repositories are found through the default
auto-configuration package. There is no @EntityScan, packagesToScan or
@EnableJpaRepositories.
- Web UI.
- Provider lists are hard-coded in web/app/components/ProvidersPanel.tsx, web/app/ai-models/page.tsx and web/app/platform-admin/llm-providers/page.tsx.
- Vendor names appear in many files under web/app: whatsapp 185, sarvam 83, elevenlabs 58, razorpay 42.
- A backend-only plugin would therefore get no UI unless the UI is driven by metadata the plugin serves.
3. Findings¶
- Can a vendor move into its own JAR today? Yes for four of them.
elevenlabs,sarvam,googleandimagegenare already separate artifacts. Each depends only on kernel utilities (crypto, security, tenancy, storage, speech), and no other vendor depends on them. - Can a vendor be left out of the build today? No, for any of them. Three things require them all:
providerscompiles against sarvam and elevenlabs.agentscompiles against all five vendor modules.TextToSpeechProviderConfigurationfails at startup unless both TTS adapters exist.- The 16 LLM chat factories cannot be moved as they are. They sit in
chatnext to the machinery every call needs: LlmModelSupport,LlmHttpClientFactory,ResilientToolCallingManagerFactoryLlmCallMetricsService,LlmCallLogService,LlmPricingCatalog- 7
*ProviderCachingStrategyclasses
Together that is about 4,550 lines. They are keyed by the closed
ProviderName, and TextModelProvider duplicates that set. Most of them
(OpenAI, OpenRouter, Grok, Mistral, DeepSeek, Groq, Meta, Ollama, Sarvam,
Azure) are thin OpenAI-compatible configurations. Moving the closed enums to
string keys is the main refactor, and it is the one that cannot be
avoided.
4. Channels are one 228-file module. It contains shared session, CRM,
handoff and trigger code next to per-channel sub-packages. Splitting it
works, but only after ChannelType is opened and the cross-channel
dispatchers (WhatsAppDispatchService, SmsDispatchService,
VoiceCallService) are behind a port. It is the most expensive split and
gives the least value early on.
5. Storage and embeddings already follow the proposed pattern. Billing
gateways are one refactor away from it.
4. Decision drivers¶
- D1. Chat hot-path latency. An LLM call must stay an in-process call. No extra network hop, no extra serialisation.
- D2. OSS adoptability. A self-hoster must be able to build or run a slim distribution, for example without the Indian-market vendors (Sarvam, Razorpay, PhonePe, UPI), and add their own provider without forking.
- D3. Nothing breaks. Today's full set stays the default image. No database migration is needed for the refactor. No config key changes. No API changes.
- D4. Operational safety. Keep to the rules already in force:
- one Flyway namespace
- single-revision deploys (ADR-0008)
APP_ENCRYPTION_KEYparity- no arbitrary third-party code inside the process that holds tenant keys
- D5. Small team. Every phase must ship value on its own and be reversible.
5. Options considered¶
A. Keep the status quo: one reactor, component-scan everything¶
- Pros: costs nothing.
- Cons: a vendor cannot be dropped. Adding a provider touches 5 to 8 files
across
providers,chat,agentsandweb. The boundary rot shown in 2.1 grows. - Rejected: it fails D2.
B. Build-time composition: Spring Boot auto-configured "provider starters"¶
Each vendor becomes a module containing:
- a @AutoConfiguration class
- a META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports file (Spring Boot: creating your own auto-configuration)
- a gate using @ConditionalOnProperty and/or @ConditionalOnClass
A distribution is then a POM (or Maven profile) that lists which starters go on the classpath.
This is the model Spring AI itself adopted for 1.0. It moved from one
monolithic auto-configure artifact to spring-ai-autoconfigure-model-{model}
plus spring-ai-starter-model-{model} per vendor, to "minimize the impact of
different versions of dependent libraries conflicting"
(Spring AI 1.0 snapshot changes).
- Pros:
- Zero runtime cost and plain in-process calls (D1).
- Standard Spring tooling.
- Works with AOT, AppCDS and GraalVM native image, because the classpath is fixed at build time (Spring Boot CDS).
- Vendor dependency trees (the AWS SDK for Bedrock, google-genai, Twilio) leave slim builds.
- Cons:
- Changing the vendor set needs a rebuild.
- The kernel enums still have to be opened.
- The component-scan overlap has to be solved (see Risk R2).
C. Runtime drop-in directory¶
- C1.
PropertiesLauncherwithloader.path. This is still supported in Boot 3.2 and later:Main-Class: org.springframework.boot.loader.launch.PropertiesLauncher, configured viaLOADER_PATH,-Dloader.pathorLoader-Path, with directories scanned for jars (Boot docs: PropertiesLauncher). Jars inproviders/go onto the same classloader, so theirAutoConfiguration.importsare picked up exactly as in Option B. - Pros: B-starters work unchanged.
- Cons: no isolation. Dependency conflicts surface as
NoSuchMethodError. The plugin can read every bean and secret. AOT and native images are impossible. - C2. Java
ServiceLoaderSPI plus aproviders/directory, Keycloak-style. Keycloak'sProvider/ProviderFactory/Spimodel is the reference (Keycloak Server Developer Guide: Service Provider Interfaces). Keycloak still loads providers into the server classpath, with a build/augment step (kc.sh build). Its providers are trusted code. - C3. Isolated classloader per plugin.
- Kafka Connect
plugin.path: a child-firstPluginClassLoaderper plugin, the thread-context classloader switched during plugin calls, plus known ServiceLoader leak pitfalls (KIP-146, KAFKA-18211). - Trino: SPI plugins, each in its own classloader, with only the
trino-spipackage shared (Trino SPI overview). -
Elasticsearch: similar (plugin authors guide).
-
Pros: dependency isolation.
- Cons: this is how you build a platform product like Trino. The SPI must be tiny and free of Spring. Spring beans cannot cross the boundary naturally. Classloaders leak on unload.
- C4. PF4J with pf4j-spring. Mature and easy to use (pf4j, pf4j-spring).
But each plugin gets its own
ApplicationContext, and host beans are not injectable without a hand-wired parent context (pf4j#244). Our adapters needtenancy,crypto,storage,LlmModelSupportand metrics, so every plugin would need a hand-wired parent context. Web controllers inside plugins are also awkward (pf4j-spring#34). - C5. OSGi. Real modularity and hot swap, but a second component model next to Spring. Spring dropped first-class OSGi support years ago, and Spring AI and Boot are not OSGi bundles. Not viable.
- C6. JPMS
ModuleLayer. A child layer per plugin is technically clean (ModuleLayer javadoc). But the whole core runs on the classpath (unnamed module), and so do Spring Boot fat jars. It is not viable without modularising everything.
Shared cons of all C options:
- Security. A runtime jar is arbitrary code inside the process that holds
decrypted BYOK keys, APP_ENCRYPTION_KEY and tenant database connections.
The Security Manager is gone: it was deprecated in JDK 17 by JEP 411 and
permanently disabled in JDK 24 by JEP 486.
In-process sandboxing is therefore not possible. The only controls are
signature or checksum allow-lists and code review.
- Performance tooling. Runtime-loaded jars defeat GraalVM's closed-world
native image (Native Image basics)
and Spring AOT (Boot AOT).
- Versioning. They make SPI compatibility a hard public contract.
D. Out-of-process providers (HTTP or gRPC microservice per vendor)¶
- Pros: full isolation. The provider can be written in any language.
- Cons:
- Every LLM token stream and tool-call round trip would cross an extra hop. Serialisation, a second timeout and retry layer, and a second failure domain all sit on the chat hot path, which runs many round trips per turn in the tool loop (fails D1).
- Streaming and back-pressure would have to be re-implemented.
- Per-vendor services multiply the deploy surface that ADR-0001 rejected.
- Where D is right is where we already use it, for heavy or untrusted runtimes:
services/sidecar (Whisper, OCR, embeddings)- the tool sandbox (ADR-0009)
mcp-stdio-proxy/ MCP runners
Keep D for that class of workload only.
E. Enforce boundaries only (Spring Modulith or ArchUnit), without changing packaging¶
- Spring Modulith treats each direct sub-package of the
@SpringBootApplicationpackage as an application module. It verifies that modules form no cycles, that nothing outside a module touches itsinternalpackages, and that declaredallowedDependenciesare respected. It supports@NamedInterfacefor exposing an SPI package (Modulith fundamentals, verification). Our layout (com.agenticai.core.<module>) fits this with no moves. - ArchUnit expresses the same rules as plain JUnit tests with no runtime dependency (ArchUnit user guide).
- Pros: cheap. Stops regressions immediately.
- Cons: does not by itself let anyone ship a slimmer build.
- Adopted as Phase 1, not as the whole answer.
6. Decision¶
Adopt E, then B, then optionally C1, and keep D for the heavy and untrusted runtimes we already run out of process.
- Enforce the boundaries with ArchUnit rules in the existing test
phase. Spring Modulith's
verify()is an optional later step; use the 1.3.x line, which matches Boot 3.4, and check the version map before adding it. - Extract a small
core-spimodule. It contains only interfaces, records, exceptions and string-key registries. It depends on nothing exceptspring-ai-modeltypes where unavoidable (ChatClient/ChatModel), plus JDK types. - Make each vendor a provider starter. Each one is auto-configured through
AutoConfiguration.importsand gated by@ConditionalOnProperty(prefix = "app.providers.<id>", name = "enabled", matchIfMissing = true)and, where relevant,@ConditionalOnClass. A distribution is anappvariant POM: appstays the "full" distribution and keeps today's module set.app-oss-slimis an example of a smaller set.- Optional, later: a
providers/directory loaded viaPropertiesLauncher/LOADER_PATHfor operator-trusted third-party starters. It is off by default. It needs a SHA-256 allow-list and is documented as "runs with full platform privileges". It will never offer a marketplace of unreviewed jars. - Do not do per-vendor microservices for LLM, speech or channel adapters.
6.1 Target shape of core-spi (package com.agenticai.spi)¶
| SPI | Replaces or generalises | Key type |
|---|---|---|
ChatModelProvider (id(), displayName(), capabilities(), create(CredentialRef, ChatModelSpec, Map<String,Object> opts): ChatClient) |
AgentChatClientFactory plus ProviderValidator plus LiveProviderModelFetcher switch arms |
String providerId ("openai", "sarvam", …) |
LlmCallSupport (a port offered to providers: http client, retries, metrics, logging, pricing, tool-calling manager) |
the concrete chat helpers each factory injects today |
n/a (provided by the kernel) |
SpeechSynthesisProvider, SpeechRecognitionProvider, VideoTranscriptionProvider |
TextToSpeechProvider etc. |
String |
EmbeddingProvider |
knowledge.embedding.EmbeddingClient |
String |
FileStorage |
storage.FileStorageService (already correct; re-export it) |
property app.storage.type |
PaymentGateway |
billing.gateway.PaymentGatewayClient (already keyed by gatewayName()) |
String |
ChannelAdapter (inbound verify and parse, outbound text and media, credential status) |
ChannelMediaSender plus the per-channel services |
String channelId |
CredentialValidator |
providers.capability.ProviderCredentialValidator (already String-keyed; this becomes the single pattern) |
String |
AgentToolContributor |
vendor tool callbacks now in agents/tools/{sarvam,google,audio,imagegen,payments} |
n/a |
ProviderDescriptor (id, display name, capabilities, credential fields schema, icon, docs URL) |
hard-coded PROVIDERS arrays in web |
String |
Rules for the SPI:
- Semantic versioning with its own spi.version.
- @since on every type.
- Additive changes only within a major version, using default methods (the
pattern AgentChatClientFactory.create(..., providerOptions) already follows).
- A core-spi-tck test-jar that every provider module runs as a contract test.
6.2 Opening the closed enums (the one large refactor)¶
- Replace uses of
ProviderName,TextModelProvider,TtsModelProvider,VoiceModelProvider,VideoModelProviderandChannelTypein routing paths withStringids, resolved through registries built fromList<…Provider>beans. - Keep the enums for one release as
@Deprecatedconstant holders (ProviderName.OPENAI.id()). They also back a JPAAttributeConverter<String>, so the stored values are byte-identical. That works because every column isEnumType.STRINGand has no CHECK constraint, so no migration is needed. PLATFORM_HOSTEDbecomes a kernel-reserved id. The special cases stay in the kernel:ProviderCredentialResolver's Sarvam branch becomes aCredentialSourcethe Sarvam starter contributes.ChatClientResolver's mapping switch disappears.- An unknown id loaded from the database, for example a provider missing from
a slim distribution, must degrade to "provider not installed on this
deployment" rather than throw.
TextToSpeechProviderFactory.getUnavailabilityReasonalready has exactly this wording.
7. Consequences¶
Positive - Adding a provider becomes one new module plus one line in a distribution POM. Today it takes 5 to 8 files across 4 modules plus the web UI. - Slim OSS distributions become possible. For example, without Sarvam, ElevenLabs, Razorpay, PhonePe and UPI payments, or without the AWS or Google SDKs. - Vendor dependency trees stop leaking into kernel modules. - The chat hot path is unchanged: in-process, same objects, same pooling.
Negative / costs
- A large but mechanical refactor of enum call sites: about 209 ProviderName
references across 78 files, plus the modality and channel enums.
- An SPI that is published has to be kept stable.
- The UI has to become metadata-driven (GET /api/providers/descriptors) for
slim builds to look right. Until then, the UI offers providers that are
missing and shows "not installed on this deployment".
8. Risks and mitigations¶
| # | Risk | Mitigation |
|---|---|---|
| R1 | Flyway: one global version namespace across controlplane and tenant, up to V365. If vendor starters ship their own db/migration/tenant/Vxxx, versions will collide. If a slim distribution leaves out a vendor whose migrations were already applied, Flyway validation fails with "applied migration not resolved locally". |
Phase 1–3: vendor migrations stay in app. Vendor tables are created even when the vendor is absent. They are empty and harmless, which keeps the schema identical for every distribution. For future third-party providers only: a separate Flyway instance per provider, with its own table = flyway_history_<providerId>, its own location db/provider/<providerId>/tenant, and run by TenantSchemaMigrator per tenant schema. That gives no shared namespace. Never move an existing V-script, because that changes checksums and location. |
| R2 | Double registration. Every class under com.agenticai.core is component-scanned. A starter in that package and listed in AutoConfiguration.imports would register twice, and conditions on scanned @Components are evaluated in the wrong order. |
Starters live in com.agenticai.provider.<id>, outside the scan root. The auto-config class @Imports its beans explicitly, or uses @ComponentScan on its own package. As a transitional alternative, add an excludeFilters pattern for com.agenticai.core.provider..*. An ArchUnit rule forbids @Component in starter packages. |
| R3 | JPA entity and repository scanning. Entities are found only under the auto-configuration package (com.agenticai.core). Starter entities in com.agenticai.provider.* would be invisible. @EntityScan replaces the defaults. |
The starter auto-config uses @AutoConfigurationPackage(basePackages = "com.agenticai.provider.<id>"). That adds the package to AutoConfigurationPackages, which both entity scanning and Spring Data repository scanning read, without replacing anything. Order it before = HibernateJpaAutoConfiguration. Add a boot test per distribution. |
| R4 | Bean-name collisions between starters (two restClient beans, two *Properties). |
Prefix every @Bean name with the provider id. Configuration properties go under app.providers.<id>.*. Existing keys (app.storage.type, app.embeddings.provider, current vendor keys) stay valid through @DeprecatedConfigurationProperty aliases for one release. |
| R5 | The agents module keeps vendor tool callbacks (34 files). |
These move with the vendor as AgentToolContributor beans. The tool resolvers (AgentMediaToolResolver, AgentIntegrationToolResolver) iterate contributors instead of naming vendors. |
| R6 | A starter is missing but a tenant row references it (for example agent.tts_provider = 'ELEVENLABS'). |
Section 6.2 degradation path, plus a startup report listing any referenced but uninstalled provider ids per tenant (read-only, logged). |
| R7 | Runtime providers/ jars: arbitrary code with full privileges; classloader and dependency conflicts; AOT/CDS disabled. |
Off by default. SHA-256 allow-list (app.providers.allowed-sha256). Refuse unsigned jars. Documented as trusted-operator-only. Official images never enable it. Anything untrusted goes out of process (D). |
| R8 | ADR-0008 single-revision deploys and APP_ENCRYPTION_KEY parity. |
Unaffected. A distribution is still one image. Starters read secrets through the kernel crypto/secrets ports and never through their own key. |
| R9 | Hidden coupling found late, such as vendor classes used in platform or workflow or passed in DTOs. |
Phase 1 ArchUnit rules make every remaining edge visible and failing (via a baseline file that only shrinks) before any code moves. |
9. Migration plan (non-breaking; default distribution = today's full set)¶
Effort is in engineer-days for one engineer who knows the codebase. It includes tests, but not staging or production soak time.
| Phase | Scope | Exit criterion | Effort |
|---|---|---|---|
| 0. Baseline | Record today's inter-module graph (this ADR). Add a CI script that diffs sibling <dependency> edges in core/*/pom.xml. |
Graph checked in; CI prints the diff | 0.5 d |
| 1. Enforce boundaries | ArchUnit test module (core/architecture-tests) with three rules: (a) kernel modules must not import elevenlabs, sarvam, google, imagegen, payments, billing.gateway.Razorpay*/PhonePe* or omnichannel.<channel>; (b) no cycles between com.agenticai.core.* slices; (c) a FreezingArchRule baseline of today's violations. Optional: Spring Modulith verify() with modules declared OPEN initially. |
Baseline frozen; new violations fail CI | 2–3 d |
| 2. Invert the obvious edges | Move ElevenLabs/Sarvam*SpeechProvider from providers into their vendor modules. Replace TextToSpeechProviderConfiguration's named constructor wiring with List<TextToSpeechProvider> (same for STT and video). Replace the ProviderCredentialResolver Sarvam branch with a CredentialSource bean. Drop providers → sarvam, elevenlabs from the POM. Note: providers → knowledge (for SidecarClient) stays until EmbeddingProvider exists. |
providers has no vendor dependency |
3–4 d |
3. core-spi plus opening the enums |
New core/spi (and core/spi-tck). Introduce String-keyed registries. Deprecated enum constant holders plus a JPA converter. Rewrite about 78 files that reference ProviderName, the ChatClientResolver mapping, the LiveProviderModelFetcher switch, and the modality enums. LlmCallSupport port extracted from chat helpers. |
All kernel routing uses String ids; stored values unchanged; full test suite green; the boot-the-jar check passes | 10–15 d |
| 4. First starters (ordered cleanest first) | See the per-module table below. Each: package com.agenticai.provider.<id>, @AutoConfiguration, imports file, app.providers.<id>.enabled, TCK test, and its tool callbacks moved out of agents. app depends on all starters, so it stays the full distribution. |
Each starter can be removed from a scratch POM and app still boots |
see table (about 20–30 d total) |
| 5. Distributions | core/distributions/{full (= current app), oss-slim}, or Maven profiles. CI boots each against deploy-postgres (pgvector + AGE). Docker build arg selects the distribution. The official image = full. |
Two images build and boot; full image byte-for-byte the same bean set as before (compare the actuator /beans diff) |
3–4 d |
| 6. Metadata-driven UI | GET /api/providers/descriptors from ProviderDescriptor beans. web renders provider and channel pickers from it, and keep today's static list as a fallback. |
Slim build hides absent providers | 5–8 d (web + web-admin) |
| 7. Optional runtime drop-in | PropertiesLauncher main class in a -pluggable image variant, LOADER_PATH=/opt/vegaduta/providers, SHA-256 allow-list check in an EnvironmentPostProcessor (the existing app-config/spring.factories hook), docs, and a sample third-party starter in examples/. |
Sample provider loads from the directory; unknown hash is refused at boot | 4–6 d |
9.1 Per-module extraction table¶
| Module / vendor | Kind | Blockers today | Target starter | Effort |
|---|---|---|---|---|
| storage (S3, Local) | FileStorage | none; already @ConditionalOnProperty |
provider-storage-s3, provider-storage-local (optional; could stay in the kernel) |
1 d |
| knowledge embeddings (OpenAI-compatible; sidecar) | EmbeddingProvider | providers → knowledge edge; SidecarClient shared with OCR |
provider-embeddings-openai-compatible; sidecar stays in the kernel (it is our own service) |
2 d |
| billing gateways (Razorpay, PhonePe) | PaymentGateway | both unconditional @Components; webhook controller routing |
provider-billing-razorpay, provider-billing-phonepe |
2–3 d |
| elevenlabs | Speech plus music | kernel adapter in providers; 10 agent tool files; 1 voice import |
provider-elevenlabs |
3–4 d |
| sarvam | LLM plus speech plus translate | ProviderName.SARVAM branch in the credential resolver; chat factory in chat; 13 agent tool files; 5 providers imports |
provider-sarvam |
4–5 d |
| google (Workspace) | Tools/integration | 8 agent tool files; workflow import |
provider-google-workspace |
2–3 d |
| imagegen | Image and video generation | depends on providers internals (26 imports); ProviderName branches |
provider-imagegen-* after Phase 3 (split per backend: Vertex, OpenAI, …) |
3–4 d |
| payments (UPI mandates) | Agent payments | agent tool callbacks; approval, eventbus, billing wiring | provider-payments-upi |
2–3 d |
| LLM chat factories (16) | ChatModelProvider | Phase 3 (LlmCallSupport, open ids). One generic provider-llm-openai-compatible can cover about 10 of them as configuration-only presets. |
provider-llm-{openai-compatible, anthropic, gemini, vertex, bedrock} |
5–7 d after Phase 3 |
| omnichannel channels (9 plus Twilio) | ChannelAdapter | ChannelType enum; shared session, CRM and handoff; dispatch services; 22 entities; per-channel web pages |
provider-channel-<x>. Deferred: do this last, one channel at a time, starting with Discord or Telegram, which have the fewest cross-links |
15–25 d total; not scheduled |
| voice, workflow, agents, chat, tenancy, … | Kernel | n/a | stay in the kernel | n/a |
10. Worked example: extracting ElevenLabs¶
ElevenLabs is the cleanest vendor. Its dependencies are kernel utilities
only, and its callers are one kernel adapter, 10 tool files in agents and one
import in voice.
10.1 Layout¶
core/
spi/ # new; artifactId core-spi
src/main/java/com/agenticai/spi/speech/SpeechSynthesisProvider.java
src/main/java/com/agenticai/spi/ProviderDescriptor.java
src/main/java/com/agenticai/spi/tools/AgentToolContributor.java
spi-tck/ # test-jar with abstract contract tests
providers-elevenlabs/ # renamed from core/elevenlabs; artifactId provider-elevenlabs
pom.xml # depends on core-spi, crypto, security, tenancy, storage, speech (NOT agents/providers)
src/main/java/com/agenticai/provider/elevenlabs/
ElevenLabsAutoConfiguration.java
ElevenLabsProperties.java
ElevenLabsSpeechSynthesisProvider.java # moved from providers/modality/ElevenLabsTextToSpeechProvider
tools/ElevenLabsSpeakToolContributor.java # moved from agents/tools/audio/*ElevenLabs*
internal/ElevenLabsClient.java, ...Service.java, ...Credential.java (entities) ...
src/main/resources/META-INF/spring/
org.springframework.boot.autoconfigure.AutoConfiguration.imports
src/test/java/.../ElevenLabsAutoConfigurationTest.java
ElevenLabsSpeechSynthesisContractTest.java # extends spi-tck
app/pom.xml # keeps <dependency>provider-elevenlabs</dependency> => full distribution unchanged
app/src/main/resources/db/migration/tenant/V89__elevenlabs.sql # STAYS HERE (R1)
10.2 SPI¶
package com.agenticai.spi.speech;
/** @since spi 1.0 */
public interface SpeechSynthesisProvider {
/** Stable id stored in agent.tts_provider, e.g. "ELEVENLABS" (kept upper-case for DB compatibility). */
String id();
byte[] synthesize(SynthesisRequest request) throws SpeechProviderException;
Availability availability(TenantRef tenant); // replaces isAvailable()/getUnavailabilityReason()
default ProviderDescriptor descriptor() { return ProviderDescriptor.of(id(), id()); }
}
The kernel keeps a SpeechSynthesisRegistry(List<SpeechSynthesisProvider>).
That replaces TextToSpeechProviderFactory plus the named
TextToSpeechProviderConfiguration.
10.3 Auto-configuration¶
package com.agenticai.provider.elevenlabs;
@AutoConfiguration(before = HibernateJpaAutoConfiguration.class)
@AutoConfigurationPackage(basePackages = "com.agenticai.provider.elevenlabs") // entities + repositories, additive (R3)
@ConditionalOnProperty(prefix = "app.providers.elevenlabs", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(ElevenLabsProperties.class) // prefix app.providers.elevenlabs
@Import({ ElevenLabsClient.class, ElevenLabsCredentialService.class, ElevenLabsVoiceService.class,
ElevenLabsGeneratedAudioService.class, GeneratedMusicService.class, VoiceQuotaService.class,
ElevenLabsController.class, ElevenLabsVoiceController.class,
ElevenLabsGeneratedAudioController.class, GeneratedMusicController.class })
public class ElevenLabsAutoConfiguration {
@Bean("elevenlabsSpeechSynthesisProvider")
SpeechSynthesisProvider elevenlabsSpeechSynthesisProvider(ElevenLabsClient client,
ElevenLabsCredentialService creds, ElevenLabsVoiceService voices) {
return new ElevenLabsSpeechSynthesisProvider(client, creds, voices);
}
@Bean("elevenlabsSpeakTools")
AgentToolContributor elevenlabsSpeakTools(ElevenLabsSpeechSynthesisProvider tts, VoiceQuotaService quota) {
return new ElevenLabsSpeakToolContributor(tts, quota);
}
}
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
com.agenticai.provider.elevenlabs.ElevenLabsAutoConfiguration
Properties, with the old keys kept as deprecated aliases for one release:
app:
providers:
elevenlabs:
enabled: true # default true => today's behaviour
base-url: https://api.elevenlabs.io
timeout: 30s
10.4 Tests¶
class ElevenLabsAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ElevenLabsAutoConfiguration.class))
.withBean(ElevenLabsCredentialRepository.class, () -> mock(ElevenLabsCredentialRepository.class))
/* ...other kernel ports mocked... */;
@Test void registersProviderByDefault() {
runner.run(ctx -> assertThat(ctx).hasSingleBean(SpeechSynthesisProvider.class)
.getBean(SpeechSynthesisProvider.class).extracting(SpeechSynthesisProvider::id).isEqualTo("ELEVENLABS"));
}
@Test void backsOffWhenDisabled() {
runner.withPropertyValues("app.providers.elevenlabs.enabled=false")
.run(ctx -> assertThat(ctx).doesNotHaveBean(SpeechSynthesisProvider.class));
}
}
class ElevenLabsSpeechSynthesisContractTest extends SpeechSynthesisProviderTck { /* WireMock-backed */ }
Plus one distribution boot test in app: a @SpringBootTest against
deploy-postgres asserts the bean set is unchanged. A second test in
oss-slim asserts that
agent.tts_provider = 'ELEVENLABS' gives "not installed on this deployment"
and not a startup failure.
10.5 What stays hard for ElevenLabs¶
ElevenLabsGeneratedAudioServiceandGeneratedMusicare used directly byagentsdemo code (DemoMusicController,DemoMagicService) and byGeneratedAttachmentResolver. Those need a genericGeneratedMediaStoreport in the kernel, which is about a day of extra work (included in the 3–4 d estimate).- The
elevenlabstables (V89) are created in every distribution. That is intentional (R1).
11. Summary¶
- Package vendors as auto-configured starters, and choose them per distribution at build time. Keep everything in-process.
- Reach that point in this order: enforce the boundaries first (Phase 1), invert the three misplaced adapters (Phase 2), then do the one unavoidable refactor, opening the closed provider and channel enums (Phase 3).
- A runtime
providers/directory is an optional, trusted-operator feature. It is not an isolation boundary. - Anything untrusted or heavy stays out of process, as it does today.
12. Status: phases 0–2 implemented¶
Written 2026-09-29 against the working tree. It reached the repository in the initial public release (2026-10-02). The boundary work that took rules (a), (b) and (c) from 728 violations to 0 landed on 2026-10-10 (see the table below and section 13). The changes are non-breaking: same beans, same provider ids, same config keys, no Flyway change, chat hot path untouched.
Phase 0: module graph¶
core/tools/module-graph/gen_module_graph.py(stdlib Python) parsescore/pom.xml<modules>and every module POM, and writescore-module-graph.md: a table, a Mermaid graph and the kernel / vendor / distribution classification. Output is deterministic;--checkexits 1 when the committed doc is stale (for CI).- Before phase 2: 42 modules, 281 sibling edges, 10 kernel → vendor module
edges, 0 vendor → vendor. After phase 2: 8 kernel → vendor edges
(
providers → sarvamandproviders → elevenlabsare gone).
Phase 1: boundary enforcement¶
com.tngtech.archunit:archunit-junit51.4.1, version pinned incore/pom.xmldependencyManagement(archunit.version), test scope incore/app.core/app/src/test/java/com/agenticai/core/archrules/ModuleBoundaryRulesTest.javaimports allcom.agenticai.coreproduction classes on app's classpath and checks threeFreezingArchRules:- (a) kernel code must not depend on vendor adapter code. Vendor code is
the
elevenlabs,sarvam,google,imagegenandpaymentspackages,billing.gateway.Razorpay*/PhonePe*, andomnichannel.{discord,email,meta,slack,teams,telegram,twilio,x}. Only classes directly incom.agenticai.core(the@SpringBootApplicationclasses and app-config's post-processors) are exempt. - (b) vendor units must not depend on each other.
- (c) top-level
com.agenticai.core.*packages are free of cycles. - Plus one strict (not frozen) rule:
providers..must not depend on any vendor code (the phase 2 exit criterion). - Baseline store:
core/app/src/test/resources/archunit_store, configured incore/app/src/test/resources/archunit.properties(allowStoreCreation=false,allowStoreUpdate=true). New violations fail the build. Fixed ones drop out of the store on the next run, so the store only shrinks. - Updating the store:
- After fixing violations, run the test and commit the smaller store.
- To accept a new violation deliberately (needs review), add
-Darchunit.freeze.refreeze=true. - For a new or renamed rule (the rule description is the store key), add
-Darchunit.freeze.store.default.allowStoreCreation=trueonce. - Always run through the reactor (
mvn -o -pl app -am test -Dtest=ModuleBoundaryRulesTest -Dsurefire.failIfNoSpecifiedTests=false) so sibling modules are read fromtarget/classesand not from stale jars. core/tools/module-graph/count_frozen_violations.pyprints the counts per rule.
| Rule | Baseline (before phase 2) | After phase 2 | After the 2026-10 boundary work |
|---|---|---|---|
| (a) kernel → vendor | 766 | 718 | 0 |
(b) vendor ↔ vendor (slice pairs; all were channel-* → channel-twilio) |
7 | 7 | 0 |
(c) package cycles (agents / tools / skills) |
3 | 3 | 0 |
| Total | 776 | 728 | 0 |
strict: providers → vendor |
48 (fails) | 0 (passes) | 0 (passes) |
All three stores are empty: any kernel → vendor or vendor → vendor dependency,
or any new cycle between top-level packages, now fails the build. Rule (c) was
cleared by moving tools and skills under agents (agents.tools,
agents.skills): the three packages already shared one Maven module because
of their mutual dependency, so the package tree now matches the module.
The kernel-owned SPIs that cleared rules (a) and (b):
agentspi(agents module until 2026-10-16, now its ownagentspimodule, see section 15): agent tool sources, media/speak/music tools, generated artifacts, the agent wallet ledger, drive upload, image and music generation ports. Adapters live in<vendor>.agenttools.speech.voicenote/speech.call(speech module): WhatsApp voice notes and live-call TTS/STT plus the shared voice quota.omnichannel.outbound,omnichannel.whatsapp,omnichannel.status: per-channel text sends, WhatsApp providers, channel status/health, bulk-email ledger.security.http.HttpStatusMapped: vendor exceptions declare their own HTTP mapping forGlobalExceptionHandler.
Phase 2: inverted adapters¶
- Moved
ElevenLabsTextToSpeechProvidertocore/elevenlabs(com.agenticai.core.elevenlabs). MovedSarvamTextToSpeechProviderandSarvamSpeechToTextProvidertocore/sarvam(com.agenticai.core.sarvam). Their code is unchanged and they still implement theproviders.modalitySPIs. Their simple class names, and therefore their default bean names, are unchanged. TextToSpeechProviderConfigurationandSpeechToTextProviderConfigurationnow injectList<…Provider>and register each bean under its owngetProvider()id.ModalityProviderRegistrationfails startup if two beans claim the same id. When a vendor is missing, lookups report the existing "not available on this deployment" reason instead of failing at startup.- New SPI
providers.ExternalProviderCredentialSource(providerName(),resolveActiveApiKey(),currentCredential()), with the implementationsarvam.SarvamProviderCredentialSource. It replaces: - the
if (providerName == ProviderName.SARVAM)branch inProviderCredentialResolver; - two Sarvam couplings that section 2.1 missed: the Sarvam merge in
ProviderService.listAll(), andProviderResponse.fromSarvam, whose mapping moved verbatim into the Sarvam source. - POMs: removed
providers → sarvamandproviders → elevenlabs, and addedsarvam → providersandelevenlabs → providers.app,app-workerandapp-jobsalready declare both vendor modules, so every distribution has the same beans as before. - Tests:
ModalityProviderConfigurationTest(providers)SarvamProviderPluginsTest(sarvam)ElevenLabsTextToSpeechProviderTest(elevenlabs)- updated
ProviderCredentialResolverTestandProviderServiceTest ProviderPluginDiscoveryTest(app): a classpath scan asserting the full distribution discovers exactly the same TTS {ElevenLabs, Sarvam}, STT {SidecarWhisper, Sarvam}, video and credential-source components.
Verification¶
mvn -o -q -T 1C test-compilepasses for the whole reactor.mvn -o -pl providers,elevenlabs,sarvam,voice,speech,agents,app -am testran 5,755 tests across 38 modules. The only failure was a wrong expectation in the newProviderPluginDiscoveryTest:OpenAIWhisperVideoProvideris@ConditionalOnPropertyand so absent from a condition-evaluating scan. After fixing that expectation, thearchrulestests pass (10/10).- Not done (no record that it was run for this change; see section 13, task 1): the jar boot check against
deploy/postgres. It was skipped because the C: drive had 9.8 GB free, below the 12 GB threshold. Before shipping a core image, run the boot-the-jar check and diff actuator/beansfor*TextToSpeechProvider,*SpeechToTextProviderandsarvamProviderCredentialSource.
13. Progress against the migration plan (as of 2026-10-14)¶
Checked against the repository on 2026-10-14: the module graph, the ArchUnit stores, and a search for the artifacts each phase promises. Nothing here was re-run; the build and the architecture tests were not executed for this update.
| Phase | State | Evidence |
|---|---|---|
| 0. Baseline | Done | core/tools/module-graph/gen_module_graph.py; generated core-module-graph.md (45 modules, 315 sibling edges, 8 kernel to vendor module edges, 0 vendor to vendor) |
| 1. Enforce boundaries | Done, beyond the plan | ModuleBoundaryRulesTest with three frozen rules and one strict rule. All three stores under core/app/src/test/resources/archunit_store are empty, so any new violation fails the build |
| 2. Invert the obvious edges | Done | providers has no vendor dependency; ElevenLabs and Sarvam speech adapters live in their own modules |
| (unplanned) Kernel-owned ports | Done 2026-10-10 | agentspi, speech.voicenote, speech.call, omnichannel.outbound, omnichannel.whatsapp, omnichannel.status, security.http.HttpStatusMapped; tools and skills moved under agents to clear the cycles |
3. core-spi and open enums |
Not started | No core/spi or core/spi-tck; ProviderName and ChannelType are still closed enums |
| 4. Provider starters | Partly started (2026-10-16) | Vendor agent tool callbacks moved out of agents into their vendor modules (section 14); agentspi is its own module and only sarvam still depends on agents (section 15). Still no AutoConfiguration.imports files and no app.providers.<id>.enabled switches |
| 5. Distributions | Not started | app, app-jobs and app-worker each list every vendor module; no slim variant |
| 6. Metadata-driven UI | Not started | No provider descriptor endpoint |
| 7. Runtime drop-in | Not started | Optional by design |
What "8 kernel to vendor module edges" meant (superseded by section 14: now 0)¶
On 2026-10-14 the module graph still listed eight Maven edges from kernel modules to vendor
modules: agents to elevenlabs, google, imagegen, payments and
sarvam; voice to elevenlabs and sarvam; workflow to google. At the
code level the kernel no longer depends on vendor classes (rule (a) is at 0).
The edges remain because the vendor adapters are still wired in at build time.
They go away when phases 3 to 5 make each vendor an optional starter. Removing
them earlier would only move code around without making any vendor removable.
Next tasks, in order¶
- Close the phase 2 verification gap. Run
scripts/ci/boot-check.sh, which already boots the real jar against the project Postgres image, then diff actuator/beansfor*TextToSpeechProvider,*SpeechToTextProviderandsarvamProviderCredentialSourceagainst the previous build. The script is not yet wired into the Makefile or CI. About half a day. Do this before the next core image ships. - Decide whether to accept this ADR. It is still "Proposed". Phase 3 costs about 10 to 15 engineer-days and touches about 78 files, and no vendor is removable until it is done. Accept it, or stop at the boundary enforcement already in place, which delivers most of the protection for none of the risk.
- Phase 3, if accepted. Add
core/spiandcore/spi-tck. Move the closed enums to string-keyed registries, keep the stored values unchanged, and extract theLlmCallSupportport. Benchmark at 32 users on the kind stage afterwards, because provider routing is on the chat turn path. - Phase 4, cleanest first. Storage, embeddings, then the billing
gateways (Razorpay, PhonePe), then ElevenLabs. Each starter must be
removable from a scratch POM with
appstill booting. Channels stay last and are not scheduled. - Phase 5. Add the
oss-slimdistribution only once at least three starters exist. Compare the full image's bean set with the current one. - Keep the docs honest. Re-run
gen_module_graph.py --checkin CI and update this section when a phase moves.
14. Status update (2026-10-16): vendor agent tools out of agents¶
This is the "tool callbacks moved out of agents" part of phase 4 (risk R5),
done ahead of phase 3 because it needed no new SPI: the kernel ports already
existed in agentspi (section 12). It is behaviour-preserving: same packages,
same class names and therefore the same default bean names, same config keys,
no Flyway change, chat hot path untouched.
What moved¶
Each vendor's com.agenticai.core.<vendor>.agenttools package moved, with its
tests, from core/agents into the vendor's own module. One commit per vendor.
| Vendor module | Main files moved | Tests moved |
|---|---|---|
elevenlabs |
13 (speak and music capability providers, GenerateMusicToolCallback, quota services) |
3 agenttools tests, agents.demo.DemoMusicControllerTest |
sarvam |
12 (translate, transliterate, transcribe, language-id tools; speak capability providers; AudioGenerationQuotaService) |
SarvamLanguageToolSourceTest |
google |
10 (Drive, Gmail, Calendar, Sheets, YouTube tools; GoogleWorkspaceToolSource; GoogleDriveFileUploader) |
2 agenttools tests |
imagegen |
8 (image and video tools and tool sources, OrchestratorImageGenerator, GeminiHostedImageModel, ImageGenGeneratedVideoReader) |
3 agenttools tests, agents.appbuilder.AppBuilderImageControllerTest |
payments |
6 (Mandate* tools, PaymentMandateToolSource, PaymentsAgentWalletLedger) |
2 agenttools tests, agents.tools.MandatePaymentToolVisibilityTest |
core/agents has no com.agenticai.core.<vendor> package left (main files:
1,165 before, 1,116 after).
POM changes¶
- Each of the five vendor modules now depends on
agents(vendor -> kernel SPI).agentsno longer depends on any vendor module. voice -> sarvam,voice -> elevenlabsandworkflow -> googleare now test scope: their main code already used only kernel ports (speech.call,agentspi.DriveFileUploader); onlyVoiceCallControllerTestandGoogleDriveOutputServiceTestneed the real adapters.omnichannelgained a test-scopesarvamdependency (WhatsAppVoiceNoteServiceTest), which it used to get throughagents.app-jobsandapp-workernow declarepaymentsdirectly; they used to get it only throughagents.appalready did. All three distributions keep the same module set and therefore the same beans.- Module graph: 0 kernel -> vendor compile edges (was 8). The vendor modules are still always on the distribution classpath, so no vendor is removable yet.
Verification done¶
- Full reactor
-DskipTests package(main and test sources compiled) fromcore/. - Moved tests, run in their new modules, plus
VoiceCallControllerTest,GoogleDriveOutputServiceTestandWhatsAppVoiceNoteServiceTest: all pass. - All
apparchrulestests (ModuleBoundaryRulesTest,StructureRulesTest,ProviderPluginDiscoveryTest,UniqueBeanNamesTest, ...): pass, ArchUnit store unchanged. scripts/ci/bean-name-collision.sh,dockerfile-poms.sh,entity-name-lint.sh,spring-constructor-lint.sh,gen_module_graph.py --check: pass.- Full test suites of the five vendor modules (376 tests): pass. The
agentssuite: 4,702 tests, one failure that is already present on the base commit and unrelated to this move (AgentEntityMappingTest.repositoryHqlStillResolvesAgainstTheMetamodelexpects 3@Querymethods onAgentRepository, which has 4). - Not done: the boot-the-jar check (
scripts/ci/boot-check.sh) with an actuator/beansdiff. The bean set should be identical because only module membership changed, but that has not been observed on a running jar.
What remains¶
- A smaller SPI for vendors. (Done for four of five vendors, see section 15.)
The vendor modules now depend on all of
agentsto reachagentspi. The target in section 6.1 is that a starter depends oncore-spionly.agentspiitself is almost free ofagentstypes (it needs only Spring AIToolCallbackandsecurity.http.HttpStatusMapped) and can become its own module. Four adapter classes still useagentsinternals (agents.Agent,agents.capability.*, andguardrails.ToolConfirmationDescriber) and need ports first. - Starters (rest of phase 4).
com.agenticai.provider.<id>packages,AutoConfiguration.imports,app.providers.<id>.enabled, TCK tests. The agent tool beans are still@Components in the scan root. - Phase 3 (open the enums,
core-spi,spi-tck) and phases 5–7 are unchanged from section 13.
15. Status update (2026-10-16): agentspi is its own module¶
Follow-up to section 14, item 1. Behaviour-preserving: the 15 files of package
com.agenticai.core.agentspi moved unchanged from core/agents to a new module
core/agentspi (same package, same types; they are interfaces, records and
exceptions, so no bean, entity, table, REST path or config key changed).
agentspidepends onsecurity(forsecurity.http.HttpStatusMapped, implemented byAgentWalletLedger.WalletLedgerException) and on Spring AI'sspring-ai-model(forToolCallback). Nothing else. It never depends onagents.agentsdepends onagentspi.google,payments,imagegenandelevenlabsnow depend onagentspifor their main code;agentsis test scope only in each (the moved testsGoogleBookingToolsTest,MandatePaymentToolVisibilityTest,AppBuilderImageControllerTestandDemoMusicControllerTestexercise kernel classes).googlealso declaresguardrailsdirectly (GoogleCalendarChangeEventToolCallbackimplementsguardrails.ToolConfirmationDescriber); it used to arrive throughagents.sarvamstill depends onagentsat compile scope. The remaining coupling is one class,sarvam.agenttools.SarvamToolsCapability, which implementsagents.capability.AgentCapability, returnsagents.capability.CapabilityAvailabilityand reads and writesagents.Agent#sarvamEnabled. MovingAgentCapabilitybehind a port (it takes the JPA entityAgent) is the next step for that edge.workflowkeeps itsagentsdependency: it is a kernel module that uses the agent kernel widely (AgentChatService,AgentService, versioning, importer, tool-call log and others), not onlyagentspi.DriveFileUploader.- The distributions (
app,app-jobs,app-worker) declareagentsdirectly and getagentspithrough it, so their module set and bean set are unchanged apart from the new jar.
Module graph: 49 modules, vendor -> agents compile edges went from 5 to 1
(sarvam). Verified: full reactor -DskipTests package, the unit tests of
agentspi, google, payments, imagegen, elevenlabs, sarvam, workflow
and agents, and the app architecture tests (ArchUnit store unchanged). Not
verified on a booted jar.